From 3c763676faf45c1e51c86c7e8e8ef2bc14ea47e3 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Thu, 6 Nov 2025 15:17:31 +0100 Subject: [PATCH 001/230] fix: update makefile to use new --ports option --- Makefile | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/Makefile b/Makefile index 4a1c64a..093ad55 100644 --- a/Makefile +++ b/Makefile @@ -22,6 +22,10 @@ BALATRO_SCRIPT := ./balatro.sh # Test ports for parallel testing TEST_PORTS := 12346 12347 12348 12349 +# Helper variables for comma-separated port list +comma := , +space := $(subst ,, ) + help: ## Show this help message @echo "$(BLUE)BalatroBot Development Makefile$(RESET)" @echo "" @@ -71,7 +75,7 @@ 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; \ + $(BALATRO_SCRIPT) --headless --fast --ports 12346; \ sleep 1; \ fi $(PYTEST) @@ -81,7 +85,7 @@ test-parallel: ## Run tests in parallel on 4 instances (auto-starts if needed) @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)); \ + $(BALATRO_SCRIPT) --headless --fast --ports $(subst $(space),$(comma),$(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/ @@ -91,7 +95,7 @@ test-migrate: ## Run replay.py on all JSONL files in tests/runs/ using 4 paralle @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)); \ + $(BALATRO_SCRIPT) --headless --fast --ports $(subst $(space),$(comma),$(TEST_PORTS)); \ sleep 1; \ fi @jsonl_files=$$(find tests/runs -name "*.jsonl" -not -name "*.skip" | sort); \ From f19c43d9a5f9d53c219e9906e428b0a8edd0c719 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Thu, 6 Nov 2025 15:40:21 +0100 Subject: [PATCH 002/230] refactor: simplify the Makefile --- Makefile | 151 +++++++------------------------------------------------ 1 file changed, 19 insertions(+), 132 deletions(-) diff --git a/Makefile b/Makefile index 093ad55..56234e6 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,152 +8,39 @@ 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 ports for parallel testing -TEST_PORTS := 12346 12347 12348 12349 - -# Helper variables for comma-separated port list -comma := , -space := $(subst ,, ) - help: ## Show this help message @echo "$(BLUE)BalatroBot Development Makefile$(RESET)" @echo "" @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-dev: ## Install package with development dependencies - @echo "$(YELLOW)Installing development dependencies...$(RESET)" - $(UV) sync --all-extras +install: ## Install balatrobot and all dependencies (including dev) + @echo "$(YELLOW)Installing all 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 . + ruff check --fix --select I . + ruff check --fix . -lint-fix: ## Run ruff linter with auto-fixes - @echo "$(YELLOW)Running ruff linter with fixes...$(RESET)" - $(RUFF) check --select I --fix . - $(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 . - @echo "$(YELLOW)Running stylua formatter...$(RESET)" - $(STYLUA) src/lua - -format-md: ## Run markdown formatter - @echo "$(YELLOW)Running markdown formatter...$(RESET)" - $(MDFORMAT) . + ruff check --select I --fix . + ruff format . + @echo "$(YELLOW)Running mdformat formatter...$(RESET)" + mdformat ./docs README.md CLAUDE.md typecheck: ## Run type checker @echo "$(YELLOW)Running type checker...$(RESET)" - $(TYPECHECK) + basedpyright src/balatrobot -quality: lint format typecheck ## Run all code quality checks - @echo "$(GREEN) All quality checks completed$(RESET)" +quality: lint typecheck format ## Run all code quality checks + @echo "$(GREEN)✓ All checks completed$(RESET)" -# Testing targets -test: ## Run tests with single Balatro instance (auto-starts if needed) +test: ## Run tests head-less @echo "$(YELLOW)Running tests...$(RESET)" - @if ! $(BALATRO_SCRIPT) --status | grep -q "12346"; then \ - echo "Starting Balatro on port 12346..."; \ - $(BALATRO_SCRIPT) --headless --fast --ports 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 --ports $(subst $(space),$(comma),$(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/ - -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 --ports $(subst $(space),$(comma),$(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)" - -test-teardown: ## Kill all Balatro instances - @echo "$(YELLOW)Killing all Balatro instances...$(RESET)" - $(BALATRO_SCRIPT) --kill - @echo "$(GREEN) All instances stopped$(RESET)" - -# 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)" + ./balatro.sh --fast --headless --ports 12346 + pytest -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)" From f00f73d024835a31a807ddf5beeaca5db5b8276f Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Thu, 6 Nov 2025 15:42:41 +0100 Subject: [PATCH 003/230] chore: add stirby as new contributor --- pyproject.toml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 8e2d84f..3ff9abd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,8 @@ description = "A framework for Balatro bot development" readme = "README.md" authors = [ { name = "S1M0N38", email = "bertolottosimone@gmail.com" }, - { name = "giewev", email = "giewev@gmail.com" }, + { name = "stirby" }, + { name = "giewev" }, { name = "besteon" }, { name = "phughesion" }, ] From 042228e2211835b0a55117bf8bff04e4d07247a2 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Thu, 6 Nov 2025 17:39:14 +0100 Subject: [PATCH 004/230] chore: rename old test file to .old.py --- .../lua/{test_protocol_errors.py => test_protocol_errors.old.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename tests/lua/{test_protocol_errors.py => test_protocol_errors.old.py} (100%) diff --git a/tests/lua/test_protocol_errors.py b/tests/lua/test_protocol_errors.old.py similarity index 100% rename from tests/lua/test_protocol_errors.py rename to tests/lua/test_protocol_errors.old.py From e9c6b7b89dbbe80dfba2a928387034d2b6147d6a Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Thu, 6 Nov 2025 17:39:32 +0100 Subject: [PATCH 005/230] test(lua): update conftest.py with two simple functions --- tests/lua/conftest.py | 266 ++++++++++++++++++++---------------------- 1 file changed, 126 insertions(+), 140 deletions(-) diff --git a/tests/lua/conftest.py b/tests/lua/conftest.py index a2742f6..7c7c6d0 100644 --- a/tests/lua/conftest.py +++ b/tests/lua/conftest.py @@ -1,165 +1,151 @@ """Lua API test-specific configuration and fixtures.""" import json -import platform -import shutil import socket -from pathlib import Path from typing import Any, Generator 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 @pytest.fixture -def tcp_client(port: int) -> Generator[socket.socket, None, None]: - """Create and clean up a TCP client socket. +def client( + host: str = "127.0.0.1", + port: int = 12346, + timeout: float = 60, + buffer_size: int = BUFFER_SIZE, +) -> Generator[socket.socket, None, None]: + """Create a TCP socket client connected to Balatro game instance. + + Args: + host: The hostname or IP address of the Balatro game server (default: "127.0.0.1"). + port: The port number the Balatro game server is listening on (default: 12346). + timeout: Socket timeout in seconds for connection and operations (default: 60). + buffer_size: Size of the socket receive buffer (default: 65536, i.e. 64KB). Yields: - Configured TCP socket for testing. + A connected TCP socket 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)) + sock.settimeout(timeout) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, buffer_size) + sock.connect((host, port)) yield sock -def send_api_message(sock: socket.socket, name: str, arguments: dict) -> None: - """Send a properly formatted JSON API message. - - Args: - sock: Socket to send through. - name: Function name to call. - arguments: Arguments dictionary for the function. - """ - message = {"name": name, "arguments": arguments} - sock.send(json.dumps(message).encode() + b"\n") - - -def receive_api_message(sock: socket.socket) -> dict[str, Any]: - """Receive a properly formatted JSON API message from the socket. - - Args: - sock: Socket to receive from. - - Returns: - Received message as a dictionary. - """ - data = sock.recv(BUFFER_SIZE) - return json.loads(data.decode().strip()) - - -def send_and_receive_api_message( - sock: socket.socket, name: str, arguments: dict +def api( + client: socket.socket, + name: str, + arguments: dict = {}, ) -> dict[str, Any]: - """Send a properly formatted JSON API message and receive the response. + """Send an 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. + sock: The TCP socket connected to the game. + name: The name of the API function to call. + arguments: Dictionary of arguments to pass to the API function (default: {}). Returns: - The game state after the message is sent and received. - """ - send_api_message(sock, name, arguments) - game_state = receive_api_message(sock) - return game_state - - -def assert_error_response( - response, - expected_error_text, - expected_context_keys=None, - expected_error_code=None, -): + The game state response as a dictionary. """ - Helper function to assert the format and content of an error response. - - 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. - - Raises: - AssertionError: If the response does not match the expected format or content. - """ - 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"] - - -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. - - Args: - sock: Socket connection to the game. - checkpoint_path: Path to the checkpoint .jkr file to load. - - Returns: - Game state after loading the checkpoint. - - Raises: - FileNotFoundError: If checkpoint file doesn't exist. - RuntimeError: If loading the checkpoint fails. - """ - if not checkpoint_path.exists(): - raise FileNotFoundError(f"Checkpoint file not found: {checkpoint_path}") - - # First, get the save directory from the game - game_state = send_and_receive_api_message(sock, "get_save_info", {}) - - # Determine the Love2D save directory - # On Linux with Steam, convert Windows paths - - 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" - ) - save_dir_str = str(linux_prefix) + "/" + save_dir_str[3:] - - save_dir = Path(save_dir_str) - - # 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) - - dest_path = test_dir / "save.jkr" - shutil.copy2(checkpoint_path, dest_path) - - # 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} - ) - - # Check for errors - if "error" in game_state: - raise RuntimeError(f"Failed to load checkpoint: {game_state['error']}") - - return game_state + payload = {"name": name, "arguments": arguments} + client.send(json.dumps(payload).encode() + b"\n") + response = client.recv(BUFFER_SIZE) + gamestate = json.loads(response.decode().strip()) + return gamestate + + +# import platform +# from pathlib import Path +# import shutil +# def assert_error_response( +# response, +# expected_error_text, +# expected_context_keys=None, +# expected_error_code=None, +# ): +# """ +# Helper function to assert the format and content of an error response. +# +# 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. +# +# Raises: +# AssertionError: If the response does not match the expected format or content. +# """ +# 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"] +# +# +# 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. +# +# Args: +# sock: Socket connection to the game. +# checkpoint_path: Path to the checkpoint .jkr file to load. +# +# Returns: +# Game state after loading the checkpoint. +# +# Raises: +# FileNotFoundError: If checkpoint file doesn't exist. +# RuntimeError: If loading the checkpoint fails. +# """ +# if not checkpoint_path.exists(): +# raise FileNotFoundError(f"Checkpoint file not found: {checkpoint_path}") +# +# # First, get the save directory from the game +# game_state = send_and_receive_api_message(sock, "get_save_info", {}) +# +# # Determine the Love2D save directory +# # On Linux with Steam, convert Windows paths +# +# 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" +# ) +# save_dir_str = str(linux_prefix) + "/" + save_dir_str[3:] +# +# save_dir = Path(save_dir_str) +# +# # 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) +# +# dest_path = test_dir / "save.jkr" +# shutil.copy2(checkpoint_path, dest_path) +# +# # 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} +# ) +# +# # Check for errors +# if "error" in game_state: +# raise RuntimeError(f"Failed to load checkpoint: {game_state['error']}") +# +# return game_state From 9de8ac00ee1598c2ea4440c822facde421602944 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Thu, 6 Nov 2025 17:40:44 +0100 Subject: [PATCH 006/230] test(lua): add test for connection.lua --- tests/lua/test_connection.py | 135 ++++++++--------------------------- 1 file changed, 28 insertions(+), 107 deletions(-) diff --git a/tests/lua/test_connection.py b/tests/lua/test_connection.py index 18c684b..b75c49e 100644 --- a/tests/lua/test_connection.py +++ b/tests/lua/test_connection.py @@ -1,119 +1,40 @@ -"""Tests for BalatroBot TCP API connection and protocol handling.""" +"""Tests for BalatroBot TCP API connection. + +This module tests the core TCP communication layer between the Python bot +and the Lua game mod, ensuring proper connection handling. + +Connection Tests: +- test_basic_connection: Verify TCP connection and basic game state retrieval +- test_rapid_messages: Test multiple rapid API calls without connection drops +- test_connection_wrong_port: Ensure connection refusal on wrong port +""" import json import socket import pytest -from .conftest import HOST, assert_error_response, receive_api_message, send_api_message +from .conftest import BUFFER_SIZE, api -def test_basic_connection(tcp_client: socket.socket) -> None: +def test_basic_connection(client: socket.socket): """Test basic TCP connection and response.""" - send_api_message(tcp_client, "get_game_state", {}) + gamestate = api(client, "get_game_state") + assert isinstance(gamestate, dict) - game_state = receive_api_message(tcp_client) - assert isinstance(game_state, dict) - -def test_rapid_messages(tcp_client: socket.socket) -> None: +def test_rapid_messages(client: socket.socket): """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) + NUM_MESSAGES = 5 + gamestates = [api(client, "get_game_state") for _ in range(NUM_MESSAGES)] + assert all(isinstance(gamestate, dict) for gamestate in gamestates) + assert len(gamestates) == NUM_MESSAGES + + +def test_connection_wrong_port(): + """Test behavior when wrong port is specified.""" + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as client: + client.settimeout(0.2) + client.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, BUFFER_SIZE) + with pytest.raises(ConnectionRefusedError): + client.connect(("127.0.0.1", 12345)) From adcbb7d4e274eb3592493ebee83be071da9aa725 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Thu, 6 Nov 2025 17:40:55 +0100 Subject: [PATCH 007/230] test(lua): add tests for protocol handling --- tests/lua/test_protocol.py | 66 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 tests/lua/test_protocol.py diff --git a/tests/lua/test_protocol.py b/tests/lua/test_protocol.py new file mode 100644 index 0000000..b177de1 --- /dev/null +++ b/tests/lua/test_protocol.py @@ -0,0 +1,66 @@ +"""Tests for BalatroBot protocol handling. + +This module tests the core TCP communication layer between the Python bot and +the Lua game mod, ensuring proper message protocol, and error response +validation. + +Protocol Payload Tests: +- test_empty_payload: Verify error response for empty messages (E001) +- test_missing_name: Test error when API call name is missing (E002) +- test_unknown_name: Test error for unknown API call names (E004) +- test_missing_arguments: Test error when arguments field is missing (E003) +- test_malformed_arguments: Test error for malformed JSON arguments (E001) +- test_invalid_arguments: Test error for invalid argument types (E005) +""" + +import json +import socket + +from .conftest import BUFFER_SIZE, api + + +def test_empty_payload(client: socket.socket): + """Test sending an empty payload.""" + client.send(b"\n") + response = client.recv(BUFFER_SIZE) + gamestate = json.loads(response.decode().strip()) + assert gamestate["error_code"] == "E001" # Invalid JSON + + +def test_missing_name(client: socket.socket): + """Test message without name field returns error response.""" + payload = {"arguments": {}} + client.send(json.dumps(payload).encode() + b"\n") + response = client.recv(BUFFER_SIZE) + gamestate = json.loads(response.decode().strip()) + assert gamestate["error_code"] == "E002" # MISSING NAME + + +def test_unknown_name(client: socket.socket): + """Test message with unknown name field returns error response.""" + gamestate = api(client, "unknown") + assert gamestate["error_code"] == "E004" # UNKNOWN NAME + + +def test_missing_arguments(client: socket.socket): + """Test message without name field returns error response.""" + payload = {"name": "get_game_state"} + client.send(json.dumps(payload).encode() + b"\n") + response = client.recv(BUFFER_SIZE) + gamestate = json.loads(response.decode().strip()) + assert gamestate["error_code"] == "E003" # MISSING ARGUMENTS + + +def test_malformed_arguments(client: socket.socket): + """Test message with malformed arguments returns error response.""" + payload = '{"name": "start_run", "arguments": {this is not valid JSON} }' + client.send(payload.encode() + b"\n") + response = client.recv(BUFFER_SIZE) + gamestate = json.loads(response.decode().strip()) + assert gamestate["error_code"] == "E001" # Invalid JSON + + +def test_invalid_arguments(client: socket.socket): + """Test that invalid JSON messages return error responses.""" + gamestate = api(client, "start_run", arguments="this is not a dict") # type: ignore + assert gamestate["error_code"] == "E005" # Invalid Arguments From 56cf26f892cfa0ab5114f5f8cfab9bc996ffc79d Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Thu, 6 Nov 2025 17:41:32 +0100 Subject: [PATCH 008/230] chore: remove pytest config from pyproject.toml --- pyproject.toml | 3 --- 1 file changed, 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 3ff9abd..8933df4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,9 +38,6 @@ lint.task-tags = ["FIX", "TODO", "HACK", "WARN", "PERF", "NOTE", "TEST"] [tool.pyright] typeCheckingMode = "basic" -[tool.pytest.ini_options] -addopts = "--cov=src/balatrobot --cov-report=term-missing --cov-report=html --cov-report=xml" - [dependency-groups] dev = [ "basedpyright>=1.29.5", From 0a071b796d84dc183ab720eda6a779b58788095d Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Sun, 9 Nov 2025 14:18:31 +0100 Subject: [PATCH 009/230] chore: remove previous implementation of the balatrobot src/lua --- src/lua/api.lua | 1515 --------------------------------------------- src/lua/log.lua | 526 ---------------- src/lua/types.lua | 373 ----------- src/lua/utils.lua | 1136 --------------------------------- 4 files changed, 3550 deletions(-) delete mode 100644 src/lua/api.lua delete mode 100644 src/lua/log.lua delete mode 100644 src/lua/types.lua delete mode 100644 src/lua/utils.lua 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/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/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 From cbceda60ce701b29c2fc33da4dbe45acffaf92be Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Sun, 9 Nov 2025 14:19:25 +0100 Subject: [PATCH 010/230] chore: remove old tests for scr/lua --- .../checkpoints/basic_shop_setup.jkr | 3 - .../endpoints/checkpoints/buy_cant_use.jkr | 3 - .../lua/endpoints/checkpoints/plasma_deck.jkr | 3 - tests/lua/endpoints/test_cash_out.py | 65 -- tests/lua/endpoints/test_get_gamestate.py | 56 -- tests/lua/endpoints/test_get_save_info.py | 68 -- tests/lua/endpoints/test_go_to_menu.py | 33 - tests/lua/endpoints/test_load_save.py | 58 -- .../endpoints/test_play_hand_or_discard.py | 277 --------- .../endpoints/test_rearrange_consumables.py | 257 -------- tests/lua/endpoints/test_rearrange_hand.py | 154 ----- tests/lua/endpoints/test_rearrange_jokers.py | 195 ------ tests/lua/endpoints/test_sell_consumable.py | 238 ------- tests/lua/endpoints/test_sell_joker.py | 277 --------- tests/lua/endpoints/test_shop.py | 582 ------------------ .../endpoints/test_skip_or_select_blind.py | 230 ------- tests/lua/endpoints/test_start_run.py | 100 --- tests/lua/endpoints/test_use_consumable.py | 411 ------------- tests/lua/test_connection.py | 40 -- tests/lua/test_log.py | 207 ------- tests/lua/test_protocol.py | 66 -- tests/lua/test_protocol_errors.old.py | 182 ------ 22 files changed, 3505 deletions(-) delete mode 100644 tests/lua/endpoints/checkpoints/basic_shop_setup.jkr delete mode 100644 tests/lua/endpoints/checkpoints/buy_cant_use.jkr delete mode 100644 tests/lua/endpoints/checkpoints/plasma_deck.jkr delete mode 100644 tests/lua/endpoints/test_cash_out.py delete mode 100644 tests/lua/endpoints/test_get_gamestate.py delete mode 100644 tests/lua/endpoints/test_get_save_info.py delete mode 100644 tests/lua/endpoints/test_go_to_menu.py delete mode 100644 tests/lua/endpoints/test_load_save.py delete mode 100644 tests/lua/endpoints/test_play_hand_or_discard.py delete mode 100644 tests/lua/endpoints/test_rearrange_consumables.py delete mode 100644 tests/lua/endpoints/test_rearrange_hand.py delete mode 100644 tests/lua/endpoints/test_rearrange_jokers.py delete mode 100644 tests/lua/endpoints/test_sell_consumable.py delete mode 100644 tests/lua/endpoints/test_sell_joker.py delete mode 100644 tests/lua/endpoints/test_shop.py delete mode 100644 tests/lua/endpoints/test_skip_or_select_blind.py delete mode 100644 tests/lua/endpoints/test_start_run.py delete mode 100644 tests/lua/endpoints/test_use_consumable.py delete mode 100644 tests/lua/test_connection.py delete mode 100644 tests/lua/test_log.py delete mode 100644 tests/lua/test_protocol.py delete mode 100644 tests/lua/test_protocol_errors.old.py 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_cash_out.py b/tests/lua/endpoints/test_cash_out.py deleted file mode 100644 index 151ec21..0000000 --- a/tests/lua/endpoints/test_cash_out.py +++ /dev/null @@ -1,65 +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 TestCashOut: - """Tests for the cash_out 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 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", {}) - - # Verify error response - assert_error_response( - response, - "Cannot cash out when not in round evaluation", - ["current_state"], - ErrorCode.INVALID_GAME_STATE.value, - ) 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_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_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_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_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_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_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_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_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 b75c49e..0000000 --- a/tests/lua/test_connection.py +++ /dev/null @@ -1,40 +0,0 @@ -"""Tests for BalatroBot TCP API connection. - -This module tests the core TCP communication layer between the Python bot -and the Lua game mod, ensuring proper connection handling. - -Connection Tests: -- test_basic_connection: Verify TCP connection and basic game state retrieval -- test_rapid_messages: Test multiple rapid API calls without connection drops -- test_connection_wrong_port: Ensure connection refusal on wrong port -""" - -import json -import socket - -import pytest - -from .conftest import BUFFER_SIZE, api - - -def test_basic_connection(client: socket.socket): - """Test basic TCP connection and response.""" - gamestate = api(client, "get_game_state") - assert isinstance(gamestate, dict) - - -def test_rapid_messages(client: socket.socket): - """Test rapid succession of get_game_state messages.""" - NUM_MESSAGES = 5 - gamestates = [api(client, "get_game_state") for _ in range(NUM_MESSAGES)] - assert all(isinstance(gamestate, dict) for gamestate in gamestates) - assert len(gamestates) == NUM_MESSAGES - - -def test_connection_wrong_port(): - """Test behavior when wrong port is specified.""" - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as client: - client.settimeout(0.2) - client.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, BUFFER_SIZE) - with pytest.raises(ConnectionRefusedError): - client.connect(("127.0.0.1", 12345)) 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.py b/tests/lua/test_protocol.py deleted file mode 100644 index b177de1..0000000 --- a/tests/lua/test_protocol.py +++ /dev/null @@ -1,66 +0,0 @@ -"""Tests for BalatroBot protocol handling. - -This module tests the core TCP communication layer between the Python bot and -the Lua game mod, ensuring proper message protocol, and error response -validation. - -Protocol Payload Tests: -- test_empty_payload: Verify error response for empty messages (E001) -- test_missing_name: Test error when API call name is missing (E002) -- test_unknown_name: Test error for unknown API call names (E004) -- test_missing_arguments: Test error when arguments field is missing (E003) -- test_malformed_arguments: Test error for malformed JSON arguments (E001) -- test_invalid_arguments: Test error for invalid argument types (E005) -""" - -import json -import socket - -from .conftest import BUFFER_SIZE, api - - -def test_empty_payload(client: socket.socket): - """Test sending an empty payload.""" - client.send(b"\n") - response = client.recv(BUFFER_SIZE) - gamestate = json.loads(response.decode().strip()) - assert gamestate["error_code"] == "E001" # Invalid JSON - - -def test_missing_name(client: socket.socket): - """Test message without name field returns error response.""" - payload = {"arguments": {}} - client.send(json.dumps(payload).encode() + b"\n") - response = client.recv(BUFFER_SIZE) - gamestate = json.loads(response.decode().strip()) - assert gamestate["error_code"] == "E002" # MISSING NAME - - -def test_unknown_name(client: socket.socket): - """Test message with unknown name field returns error response.""" - gamestate = api(client, "unknown") - assert gamestate["error_code"] == "E004" # UNKNOWN NAME - - -def test_missing_arguments(client: socket.socket): - """Test message without name field returns error response.""" - payload = {"name": "get_game_state"} - client.send(json.dumps(payload).encode() + b"\n") - response = client.recv(BUFFER_SIZE) - gamestate = json.loads(response.decode().strip()) - assert gamestate["error_code"] == "E003" # MISSING ARGUMENTS - - -def test_malformed_arguments(client: socket.socket): - """Test message with malformed arguments returns error response.""" - payload = '{"name": "start_run", "arguments": {this is not valid JSON} }' - client.send(payload.encode() + b"\n") - response = client.recv(BUFFER_SIZE) - gamestate = json.loads(response.decode().strip()) - assert gamestate["error_code"] == "E001" # Invalid JSON - - -def test_invalid_arguments(client: socket.socket): - """Test that invalid JSON messages return error responses.""" - gamestate = api(client, "start_run", arguments="this is not a dict") # type: ignore - assert gamestate["error_code"] == "E005" # Invalid Arguments diff --git a/tests/lua/test_protocol_errors.old.py b/tests/lua/test_protocol_errors.old.py deleted file mode 100644 index 2bc7265..0000000 --- a/tests/lua/test_protocol_errors.old.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 From 4f73a0e81426978af07e858aecf6f1f699144c39 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Sun, 9 Nov 2025 14:48:42 +0100 Subject: [PATCH 011/230] feat(lua): add BalatroBot settings --- src/lua/settings.lua | 326 ++++++++++++++++++++----------------------- 1 file changed, 155 insertions(+), 171 deletions(-) diff --git a/src/lua/settings.lua b/src/lua/settings.lua index aef0eba..1d1472f 100644 --- a/src/lua/settings.lua +++ b/src/lua/settings.lua @@ -1,247 +1,231 @@ --- 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: + + - BALATROBOT_HOST: the hostname when the TCP server is running. + Type string (default: 127.0.0.1) + + - BALATROBOT_PORT: the port when the TCP server is running. + Type string (default: 12346) + + - BALATROBOT_HEADLESS: whether to run in headless mode. + 1 for actiavate the headeless mode, 0 for running headed (default: 0) + + - BALATROBOT_FAST: whether to run in fast mode. + 1 for actiavate the fast mode, 0 for running slow (default: 0) + + - BALATROBOT_RENDER_ON_API: whether to render frames only on API calls. + 1 for actiavate the render on API mode, 0 for normal rendering (default: 0) + + - BALATROBOT_AUDIO: whether to play audio. + 1 for actiavate the audio mode, 0 for no audio (default: 0) + + - BALATROBOT_DEBUG: whether enable debug mode. It requires DebugPlus mod to be running. + 1 for actiavate the debug mode, 0 for no debug (default: 0) +]] + +---@diagnostic disable: duplicate-set-field + +---@class BB_SETTINGS +BB_SETTINGS = { + ---@type string + host = os.getenv("BALATROBOT_HOST") or "127.0.0.1", + ---@type number + port = tonumber(os.getenv("BALATROBOT_PORT")) or 12346, + ---@type boolean + headless = os.getenv("BALATROBOT_HEADLESS") == "1" or false, + ---@type boolean + fast = os.getenv("BALATROBOT_FAST") == "1" or false, + ---@type boolean + render_on_api = os.getenv("BALATROBOT_RENDER_ON_API") == "1" or false, + ---@type boolean + audio = os.getenv("BALATROBOT_AUDIO") == "1" or false, + ---@type boolean + debug = os.getenv("BALATROBOT_DEBUG") == "1" or false, } --- Apply Love2D patches for performance -local function apply_love_patches() - local original_update = love.update - ---@diagnostic disable-next-line: duplicate-set-field +-- Global flag to trigger rendering (used by render_on_api) +---@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(_) - original_update(config.dt) + love_update(dt) end + sendDebugMessage("Patched love.update with dt=" .. dt, "BB.SETTINGS") end --- Configure Balatro G globals for speed -local function configure_balatro_speed() - -- Skip intro and splash screens - G.SETTINGS.skip_splash = "Yes" +--- 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 - - -- 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 - - 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 - - -- 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 - - -- Window settings - restore normal display - G.SETTINGS.WINDOW = G.SETTINGS.WINDOW or {} - G.SETTINGS.WINDOW.vsync = 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 - - -- Skip intro but allow normal game flow - G.SETTINGS.skip_splash = "Yes" - - sendInfoMessage("BalatroBot: Running in normal mode") - end + 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 --- Configure headless mode optimizations +--- Configures headless mode by minimizing and hiding the window +--- Disables all rendering operations, graphics, and window updates +---@return nil local function configure_headless() - if not config.headless then - return - end - - -- Hide the window instead of closing it 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 --- 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") - 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 + +--- 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 - G.BALATROBOT_PORT = port or "12346" - G.BALATROBOT_HOST = host or "127.0.0.1" +--- 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) +BB_SETTINGS.setup = function() + configure_love_update() + configure_settings() - -- Apply Love2D performance patches - apply_love_patches() + 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 + + if BB_SETTINGS.headless then + configure_headless() + end - -- Configure Balatro speed settings - configure_balatro_speed() + 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.audio then + configure_audio() + end end From 09e7b522814e42d5fe87ece8fd3e501b4d2070e5 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Sun, 9 Nov 2025 16:37:04 +0100 Subject: [PATCH 012/230] feat(lua): add server module --- src/lua/core/server.lua | 228 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 228 insertions(+) create mode 100644 src/lua/core/server.lua diff --git a/src/lua/core/server.lua b/src/lua/core/server.lua new file mode 100644 index 0000000..99eb3cf --- /dev/null +++ b/src/lua/core/server.lua @@ -0,0 +1,228 @@ +-- src/lua/core/server.lua +-- TCP Server for BalatroBot API +-- +-- Simplified single-client server (assumes only one client connects) +-- +-- Responsibilities: +-- - Create and bind TCP socket (non-blocking) on port 12346 +-- - Accept client connections (overwrites previous client) +-- - Receive JSON-only requests (newline-delimited) +-- - Pass requests to Dispatcher +-- - Send responses back to client + +local socket = require("socket") +local json = require("json") + +BB_SERVER = { + + -- Configuration + ---@type string + host = BB_SETTINGS.host, + ---@type integer + port = BB_SETTINGS.port, + + -- Sockets + ---@type TCPSocketServer? + server_socket = nil, + ---@type TCPSocketClient? + client_socket = nil, +} + +--- Initialize the TCP server +--- Creates and binds a non-blocking TCP socket on the configured port +--- @return boolean success +function BB_SERVER.init() + -- Create TCP socket + local server, err = socket.tcp() + if not server then + sendErrorMessage("Failed to create socket: " .. tostring(err), "BB.SERVER") + return false + end + + -- Bind to port + 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 + + -- Start listening (backlog of 1 for single client model) + 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 + + -- Set non-blocking mode + server:settimeout(0) + + BB_SERVER.server_socket = server + + sendDebugMessage("Listening on " .. BB_SERVER.host .. ":" .. BB_SERVER.port, "BB.SERVER") + return true +end + +--- Accept a new client connection +--- Simply accepts any incoming connection (overwrites previous client if any) +--- @return boolean accepted +function BB_SERVER.accept() + if not BB_SERVER.server_socket then + return false + end + + -- Accept new client (will overwrite any existing client) + local client, err = BB_SERVER.server_socket:accept() + if err then + if err ~= "timeout" then + sendErrorMessage("Failed to accept client: " .. tostring(err), "BB.SERVER") + return false + end + return false + end + if client and not err then + client:settimeout(0) -- Non-blocking + BB_SERVER.client_socket = client + sendDebugMessage("Client connected", "BB.SERVER") + return true + end + + return false +end + +--- Receive and parse a single JSON request from client +--- Ultra-simple protocol: JSON + '\n' (nothing else allowed) +--- Max payload: 256 bytes +--- Rejects pipelined/multiple messages +--- @return table[] requests Array with at most one parsed JSON request object +function BB_SERVER.receive() + if not BB_SERVER.client_socket then + return {} + end + + -- Read one line (non-blocking) + BB_SERVER.client_socket:settimeout(0) + local line, err = BB_SERVER.client_socket:receive("*l") + + if not line then + return {} -- No data available or connection closed + end + + -- Check message size (line doesn't include the \n, so +1 for newline) + if #line + 1 > 256 then + BB_SERVER.send_error("Request too large: maximum 256 bytes including newline", "PROTO_PAYLOAD") + return {} + end + + -- Check if there's additional data waiting (pipelined requests) + BB_SERVER.client_socket:settimeout(0) + local peek, peek_err = BB_SERVER.client_socket:receive(1) + if peek then + -- There's more data! This means client sent multiple messages + BB_SERVER.send_error( + "Invalid request: data after newline (pipelining/multiple messages not supported)", + "PROTO_PAYLOAD" + ) + return {} + end + + -- Ignore empty lines + if line == "" then + return {} + end + + -- Check that JSON starts with '{' (must be object, not array/primitive) + local trimmed = line:match("^%s*(.-)%s*$") + if not trimmed:match("^{") then + BB_SERVER.send_error("Invalid JSON in request: must be object (start with '{')", "PROTO_INVALID_JSON") + return {} + end + + -- Parse JSON + local success, parsed = pcall(json.decode, line) + if success and type(parsed) == "table" then + return { parsed } + else + BB_SERVER.send_error("Invalid JSON in request", "PROTO_INVALID_JSON") + return {} + end +end + +--- Send a response to the client +-- @param response table Response object to encode as JSON +-- @return boolean success +function BB_SERVER.send_response(response) + if not BB_SERVER.client_socket then + return false + end + + -- Encode to JSON + local success, json_str = pcall(json.encode, response) + if not success then + sendDebugMessage("Failed to encode response: " .. tostring(json_str), "BB.SERVER") + return false + end + + -- Send with newline delimiter + local data = json_str .. "\n" + local bytes, err = BB_SERVER.client_socket:send(data) + + if err then + sendDebugMessage("Failed to send response: " .. err, "BB.SERVER") + return false + end + + return true +end + +--- Send an error response to the client +-- @param message string Error message +-- @param error_code string Error code (e.g., "PROTO_INVALID_JSON") +function BB_SERVER.send_error(message, error_code) + BB_SERVER.send_response({ + error = message, + error_code = error_code, + }) +end + +--- Update loop - called from game's update cycle +-- Handles accepting connections, receiving requests, and dispatching +-- @param dispatcher table? Dispatcher module for routing requests (optional for now) +function BB_SERVER.update(dispatcher) + if not BB_SERVER.server_socket then + return + end + + -- Accept new connections (single client only) + BB_SERVER.accept() + + -- Receive and process requests + if BB_SERVER.client_socket then + local requests = BB_SERVER.receive() + + for _, request in ipairs(requests) do + if dispatcher and dispatcher.dispatch then + -- Pass to Dispatcher when available + dispatcher.dispatch(request, BB_SERVER.client_socket) + else + -- Placeholder: send error that dispatcher not ready + BB_SERVER.send_error("Server not fully initialized (dispatcher not ready)", "STATE_NOT_READY") + end + end + end +end + +--- Cleanup and close server +function BB_SERVER.close() + if BB_SERVER.client_socket then + BB_SERVER.client_socket:close() + BB_SERVER.client_socket = nil + end + + if BB_SERVER.server_socket then + BB_SERVER.server_socket:close() + BB_SERVER.server_socket = nil + sendDebugMessage("Server closed", "BB.SERVER") + end +end + +return BB_SERVER From b49e34aecd2aaf7a582a21bf0193caf82ccd4b6b Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Sun, 9 Nov 2025 16:37:29 +0100 Subject: [PATCH 013/230] test(lua): add port fixture to conftest --- tests/lua/conftest.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/lua/conftest.py b/tests/lua/conftest.py index 7c7c6d0..abd6b40 100644 --- a/tests/lua/conftest.py +++ b/tests/lua/conftest.py @@ -34,6 +34,12 @@ def client( yield sock +@pytest.fixture +def port() -> int: + """Return the default Balatro server port.""" + return 12346 + + def api( client: socket.socket, name: str, From d554c7d957d7729cd50c903754171d3a81eaa960 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Sun, 9 Nov 2025 16:37:42 +0100 Subject: [PATCH 014/230] test(lua): add __init__.py --- tests/lua/core/__init__.py | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 tests/lua/core/__init__.py 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 From 3792086f0aaeeabbab0209993a49b0b08ea36896 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Sun, 9 Nov 2025 16:37:54 +0100 Subject: [PATCH 015/230] test(lua): add test for core/server.lua --- tests/lua/core/test_server.py | 346 ++++++++++++++++++++++++++++++++++ 1 file changed, 346 insertions(+) create mode 100644 tests/lua/core/test_server.py diff --git a/tests/lua/core/test_server.py b/tests/lua/core/test_server.py new file mode 100644 index 0000000..1734a96 --- /dev/null +++ b/tests/lua/core/test_server.py @@ -0,0 +1,346 @@ +""" +Integration tests for BB_SERVER TCP communication. + +Test classes are organized by BB_SERVER function: +- TestBBServerInit: BB_SERVER.init() - server initialization and port binding +- TestBBServerAccept: BB_SERVER.accept() - client connection handling +- TestBBServerReceive: BB_SERVER.receive() - protocol enforcement and parsing +- TestBBServerSendResponse: BB_SERVER.send_response() - response sending +""" + +import errno +import json +import socket + +import pytest + +from tests.lua.conftest import BUFFER_SIZE + + +class TestBBServerInit: + """Tests for BB_SERVER.init() - 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.""" + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(2) + try: + sock.connect(("127.0.0.1", port)) + assert sock.fileno() != -1, f"Should connect to port {port}" + finally: + sock.close() + + def test_port_is_exclusively_bound(self, port: int) -> None: + """Test that server exclusively binds the port.""" + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + try: + with pytest.raises(OSError) as exc_info: + sock.bind(("127.0.0.1", port)) + assert exc_info.value.errno == errno.EADDRINUSE + finally: + sock.close() + + def test_port_not_reusable_while_running(self, port: int) -> None: + """Test that port cannot be reused while server is running.""" + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + try: + with pytest.raises(OSError) as exc_info: + sock.bind(("127.0.0.1", port)) + sock.listen(1) + assert exc_info.value.errno == errno.EADDRINUSE + finally: + sock.close() + + +class TestBBServerAccept: + """Tests for BB_SERVER.accept() - client connection handling.""" + + def test_accepts_connections(self, client: socket.socket) -> None: + """Test that server accepts client connections.""" + assert client.fileno() != -1, "Client should connect successfully" + + def test_sequential_connections(self, port: int) -> None: + """Test that server handles sequential connections correctly.""" + for i in range(3): + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(2) + try: + sock.connect(("127.0.0.1", port)) + assert sock.fileno() != -1, f"Connection {i + 1} should succeed" + finally: + sock.close() + + def test_rapid_sequential_connections(self, port: int) -> None: + """Test server handles rapid sequential connections.""" + for i in range(5): + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(2) + try: + sock.connect(("127.0.0.1", port)) + assert sock.fileno() != -1, f"Rapid connection {i + 1} should succeed" + finally: + sock.close() + + def test_multiple_concurrent_connections(self, port: int) -> None: + """Test server behavior with multiple concurrent connection attempts.""" + sockets = [] + try: + for _ in range(3): + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(1) + try: + sock.connect(("127.0.0.1", port)) + sockets.append(sock) + except (socket.timeout, ConnectionRefusedError, OSError): + sock.close() + + # At least one connection should succeed + assert len(sockets) >= 1, "At least one connection should succeed" + + for sock in sockets: + assert sock.fileno() != -1, "Connected sockets should be valid" + finally: + for sock in sockets: + sock.close() + + def test_immediate_disconnect(self, port: int) -> None: + """Test server handles clients that disconnect immediately.""" + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(2) + sock.connect(("127.0.0.1", port)) + sock.close() + + # Server should still accept new connections + sock2 = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock2.settimeout(2) + try: + sock2.connect(("127.0.0.1", port)) + assert sock2.fileno() != -1, ( + "Server should accept connection after disconnect" + ) + finally: + sock2.close() + + def test_reconnect_after_graceful_disconnect(self, port: int) -> None: + """Test client can reconnect after clean disconnect.""" + # First connection + sock1 = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock1.settimeout(2) + sock1.connect(("127.0.0.1", port)) + + # Send a request + msg = json.dumps({"name": "health", "arguments": {}}) + "\n" + sock1.send(msg.encode()) + sock1.recv(BUFFER_SIZE) # Consume response + + # Close connection + sock1.close() + + # Reconnect + sock2 = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock2.settimeout(2) + try: + sock2.connect(("127.0.0.1", port)) + assert sock2.fileno() != -1, "Should reconnect successfully" + + # Verify new connection works + sock2.send(msg.encode()) + response = sock2.recv(BUFFER_SIZE) + assert len(response) > 0, "Should receive response after reconnect" + finally: + sock2.close() + + def test_client_disconnect_without_sending(self, port: int) -> None: + """Test server handles client that connects but never sends data.""" + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(2) + sock.connect(("127.0.0.1", port)) + sock.close() + + # Server should still accept new connections + sock2 = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock2.settimeout(2) + try: + sock2.connect(("127.0.0.1", port)) + assert sock2.fileno() != -1 + finally: + sock2.close() + + +class TestBBServerReceive: + """Tests for BB_SERVER.receive() - protocol enforcement and parsing. + + Tests verify error responses for protocol violations: + - Message size limit (256 bytes including newline) + - Pipelining rejection (multiple messages) + - JSON validation (must be object, not string/number/array) + - Invalid JSON syntax + - Edge cases (whitespace, nested objects, escaped characters) + """ + + def test_message_too_large(self, client: socket.socket) -> None: + """Test that messages exceeding 256 bytes are rejected.""" + # Create message > 255 bytes (line + newline must be <= 256) + large_msg = {"name": "test", "data": "x" * 300} + msg = json.dumps(large_msg) + "\n" + assert len(msg) > 256, "Test message should exceed 256 bytes" + + client.send(msg.encode()) + + # Give game loop time to process and respond + + response = client.recv(BUFFER_SIZE).decode().strip() + data = json.loads(response) + + assert "error" in data + assert "error_code" in data + assert data["error_code"] == "PROTO_PAYLOAD" + assert "too large" in data["error"].lower() + + def test_pipelined_messages_rejected(self, client: socket.socket) -> None: + """Test that sending multiple messages at once is rejected.""" + msg1 = json.dumps({"name": "health", "arguments": {}}) + "\n" + msg2 = json.dumps({"name": "health", "arguments": {}}) + "\n" + + client.send((msg1 + msg2).encode()) + + response = client.recv(BUFFER_SIZE).decode().strip() + # May get multiple responses, take the first one + first_response = response.split("\n")[0] + data = json.loads(first_response) + + assert "error" in data + assert "error_code" in data + assert data["error_code"] == "PROTO_PAYLOAD" + + def test_invalid_json_syntax(self, client: socket.socket) -> None: + """Test that malformed JSON is rejected.""" + client.send(b"{invalid json}\n") + + response = client.recv(BUFFER_SIZE).decode().strip() + data = json.loads(response) + + assert "error" in data + assert "error_code" in data + assert data["error_code"] == "PROTO_INVALID_JSON" + + def test_json_string_rejected(self, client: socket.socket) -> None: + """Test that JSON strings are rejected (must be object).""" + client.send(b'"just a string"\n') + + response = client.recv(BUFFER_SIZE).decode().strip() + data = json.loads(response) + + assert "error" in data + assert "error_code" in data + assert data["error_code"] == "PROTO_INVALID_JSON" + + def test_json_number_rejected(self, client: socket.socket) -> None: + """Test that JSON numbers are rejected (must be object).""" + client.send(b"42\n") + + response = client.recv(BUFFER_SIZE).decode().strip() + data = json.loads(response) + + assert "error" in data + assert "error_code" in data + assert data["error_code"] == "PROTO_INVALID_JSON" + + def test_json_array_rejected(self, client: socket.socket) -> None: + """Test that JSON arrays are rejected (must be object starting with '{').""" + client.send(b'["array", "of", "values"]\n') + + response = client.recv(BUFFER_SIZE).decode().strip() + data = json.loads(response) + + assert "error" in data + assert "error_code" in data + assert data["error_code"] == "PROTO_INVALID_JSON" + + def test_only_whitespace_line_rejected(self, client: socket.socket) -> None: + """Test that whitespace-only lines are rejected as invalid JSON.""" + # Send whitespace-only line (gets trimmed to empty string, fails '{' check) + client.send(b" \t \n") + + response = client.recv(BUFFER_SIZE).decode().strip() + data = json.loads(response) + + # Should be rejected as invalid JSON (trimmed to empty, doesn't start with '{') + assert "error" in data + assert data["error_code"] == "PROTO_INVALID_JSON" + + def test_valid_json_with_nested_objects(self, client: socket.socket) -> None: + """Test complex valid JSON with nested structures is accepted.""" + complex_msg = { + "name": "test", + "arguments": { + "nested": {"level1": {"level2": {"level3": "value"}}}, + "array": [1, 2, 3], + "mixed": {"a": [{"b": "c"}]}, + }, + } + msg = json.dumps(complex_msg) + "\n" + + # Ensure it's under size limit + if len(msg) <= 256: + client.send(msg.encode()) + + response = client.recv(BUFFER_SIZE).decode().strip() + data = json.loads(response) + + # Should be parsed successfully (not a protocol error) + if "error" in data: + assert data["error_code"] != "PROTO_INVALID_JSON" + + def test_json_with_escaped_characters(self, client: socket.socket) -> None: + """Test JSON with escaped quotes, newlines in strings, etc.""" + msg = json.dumps({"name": "test", "data": 'quotes: "hello"\nnewline'}) + "\n" + + if len(msg) <= 256: + client.send(msg.encode()) + + response = client.recv(BUFFER_SIZE).decode().strip() + data = json.loads(response) + + # Should be parsed successfully + if "error" in data: + assert data["error_code"] != "PROTO_INVALID_JSON" + + +class TestBBServerSendResponse: + """Tests for BB_SERVER.send_response() and send_error() - response sending.""" + + def test_server_accepts_data(self, client: socket.socket) -> None: + """Test that server accepts data from connected clients.""" + test_data = b"test\n" + bytes_sent = client.send(test_data) + assert bytes_sent == len(test_data), "Should send all data" + + def test_multiple_sequential_valid_requests(self, client: socket.socket) -> None: + """Test handling multiple valid requests sent sequentially (not pipelined).""" + # Send first request + msg1 = json.dumps({"name": "health", "arguments": {}}) + "\n" + client.send(msg1.encode()) + + response1 = client.recv(BUFFER_SIZE).decode().strip() + data1 = json.loads(response1) + assert "status" in data1 # Health endpoint returns status + + # Send second request on same connection + msg2 = json.dumps({"name": "health", "arguments": {}}) + "\n" + client.send(msg2.encode()) + + response2 = client.recv(BUFFER_SIZE).decode().strip() + data2 = json.loads(response2) + assert "status" in data2 + + def test_whitespace_around_json_accepted(self, client: socket.socket) -> None: + """Test that JSON with leading/trailing whitespace is accepted.""" + msg = " " + json.dumps({"name": "health", "arguments": {}}) + " \n" + client.send(msg.encode()) + response = client.recv(BUFFER_SIZE).decode().strip() + data = json.loads(response) + + # Should be processed successfully (whitespace trimmed at line 134) + assert "status" in data or "error" in data From 9ec20ecb0cedeeed82d6107b4296038407d24e2a Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Mon, 10 Nov 2025 14:37:33 +0100 Subject: [PATCH 016/230] style(lua): remove useless return and unused variable --- src/lua/core/server.lua | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/lua/core/server.lua b/src/lua/core/server.lua index 99eb3cf..0695d9b 100644 --- a/src/lua/core/server.lua +++ b/src/lua/core/server.lua @@ -101,7 +101,7 @@ function BB_SERVER.receive() -- Read one line (non-blocking) BB_SERVER.client_socket:settimeout(0) - local line, err = BB_SERVER.client_socket:receive("*l") + local line, _ = BB_SERVER.client_socket:receive("*l") if not line then return {} -- No data available or connection closed @@ -115,7 +115,7 @@ function BB_SERVER.receive() -- Check if there's additional data waiting (pipelined requests) BB_SERVER.client_socket:settimeout(0) - local peek, peek_err = BB_SERVER.client_socket:receive(1) + local peek, _ = BB_SERVER.client_socket:receive(1) if peek then -- There's more data! This means client sent multiple messages BB_SERVER.send_error( @@ -224,5 +224,3 @@ function BB_SERVER.close() sendDebugMessage("Server closed", "BB.SERVER") end end - -return BB_SERVER From 36842850f21ad4d8ac22dc1c124ab20606dac8c8 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Tue, 11 Nov 2025 00:38:52 +0100 Subject: [PATCH 017/230] feat(lua): add close previous client socket on new connection --- src/lua/core/server.lua | 29 ++++++++++++++--------------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/src/lua/core/server.lua b/src/lua/core/server.lua index 0695d9b..e7e737f 100644 --- a/src/lua/core/server.lua +++ b/src/lua/core/server.lua @@ -80,6 +80,12 @@ function BB_SERVER.accept() return false end if client and not err then + -- Close previous client socket if exists (single-client model) + if BB_SERVER.client_socket then + BB_SERVER.client_socket:close() + BB_SERVER.client_socket = nil + end + client:settimeout(0) -- Non-blocking BB_SERVER.client_socket = client sendDebugMessage("Client connected", "BB.SERVER") @@ -92,7 +98,7 @@ end --- Receive and parse a single JSON request from client --- Ultra-simple protocol: JSON + '\n' (nothing else allowed) --- Max payload: 256 bytes ---- Rejects pipelined/multiple messages +--- Non-blocking: returns empty array if no complete request available --- @return table[] requests Array with at most one parsed JSON request object function BB_SERVER.receive() if not BB_SERVER.client_socket then @@ -101,9 +107,14 @@ function BB_SERVER.receive() -- Read one line (non-blocking) BB_SERVER.client_socket:settimeout(0) - local line, _ = BB_SERVER.client_socket:receive("*l") + local line, err = BB_SERVER.client_socket:receive("*l") if not line then + -- If connection closed, clean up the socket reference + if err == "closed" then + BB_SERVER.client_socket:close() + BB_SERVER.client_socket = nil + end return {} -- No data available or connection closed end @@ -113,18 +124,6 @@ function BB_SERVER.receive() return {} end - -- Check if there's additional data waiting (pipelined requests) - BB_SERVER.client_socket:settimeout(0) - local peek, _ = BB_SERVER.client_socket:receive(1) - if peek then - -- There's more data! This means client sent multiple messages - BB_SERVER.send_error( - "Invalid request: data after newline (pipelining/multiple messages not supported)", - "PROTO_PAYLOAD" - ) - return {} - end - -- Ignore empty lines if line == "" then return {} @@ -164,7 +163,7 @@ function BB_SERVER.send_response(response) -- Send with newline delimiter local data = json_str .. "\n" - local bytes, err = BB_SERVER.client_socket:send(data) + local _, err = BB_SERVER.client_socket:send(data) if err then sendDebugMessage("Failed to send response: " .. err, "BB.SERVER") From a20359d2bc53d6d127c6e2504c87ff4c40fe60ad Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Tue, 11 Nov 2025 00:39:13 +0100 Subject: [PATCH 018/230] feat(lua): add dispatcher module --- src/lua/core/dispatcher.lua | 262 ++++++++++++++++++++++++++++++++++++ 1 file changed, 262 insertions(+) create mode 100644 src/lua/core/dispatcher.lua diff --git a/src/lua/core/dispatcher.lua b/src/lua/core/dispatcher.lua new file mode 100644 index 0000000..348fb75 --- /dev/null +++ b/src/lua/core/dispatcher.lua @@ -0,0 +1,262 @@ +-- src/lua/core/dispatcher.lua +-- Request Dispatcher with 4-Tier Validation +-- +-- Routes API calls to endpoints with comprehensive validation: +-- 1. Protocol validation (has name, arguments) +-- 2. Schema validation (via Validator) +-- 3. Game state validation (requires_state check) +-- 4. Execute endpoint (catch semantic errors and enrich) +-- +-- Responsibilities: +-- - Auto-discover and register all endpoints at startup (fail-fast) +-- - Validate request structure at protocol level +-- - Route requests to appropriate endpoints +-- - Delegate schema validation to Validator module +-- - Enforce game state requirements +-- - Execute endpoints with comprehensive error handling +-- - Send responses and rich error messages via Server module + +---@type Validator +local Validator = assert(SMODS.load_file("src/lua/core/validator.lua"))() +---@type ErrorCodes +local errors = assert(SMODS.load_file("src/lua/utils/errors.lua"))() + +---@class Dispatcher +---@field endpoints table Endpoint registry mapping names to modules +---@field Server table? Reference to Server module for sending responses +BB_DISPATCHER = { + + -- Endpoint registry: name -> endpoint module + ---@type table + endpoints = {}, + + -- Reference to Server module (set after initialization) + ---@type table? + Server = nil, +} + +--- Validate that an endpoint module has the required structure +---@param endpoint Endpoint The endpoint module to validate +---@return boolean success +---@return string? error_message +local function validate_endpoint_structure(endpoint) + -- Check required fields + 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 + + -- requires_state is optional but must be nil or table if present + 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 + + -- Validate schema structure (basic check) + 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 + +--- Register a single endpoint +--- Validates the endpoint structure and adds it to the registry +---@param endpoint Endpoint The endpoint module to register +---@return boolean success +---@return string? error_message +function BB_DISPATCHER.register(endpoint) + -- Validate endpoint structure + local valid, err = validate_endpoint_structure(endpoint) + if not valid then + return false, err + end + + -- Check for duplicate names + if BB_DISPATCHER.endpoints[endpoint.name] then + return false, "Endpoint '" .. endpoint.name .. "' is already registered" + end + + -- Register endpoint + BB_DISPATCHER.endpoints[endpoint.name] = endpoint + sendDebugMessage("Registered endpoint: " .. endpoint.name, "BB.DISPATCHER") + + return true +end + +--- Load all endpoint modules from a directory +--- Loads .lua files and registers each endpoint (fail-fast) +---@param endpoint_files string[] List of endpoint file paths relative to mod root +---@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") + + -- Load endpoint module (fail-fast on errors) + 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 + + -- Register endpoint (fail-fast on validation errors) + 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 + +--- Initialize the dispatcher +--- Loads all endpoints from the provided list +---@param server_module table Reference to Server module for sending responses +---@param endpoint_files string[]? Optional list of endpoint file paths (default: health and gamestate) +---@return boolean success +function BB_DISPATCHER.init(server_module, endpoint_files) + BB_DISPATCHER.Server = server_module + + -- Default endpoint files if none provided + endpoint_files = endpoint_files or { + "src/lua/endpoints/health.lua", + } + + -- Load all endpoints (fail-fast) + 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 + +--- Send an error response via Server module +---@param message string Error message +---@param error_code string Error code +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_error(message, error_code) +end + +--- Dispatch a request to the appropriate endpoint +--- Performs 4-tier validation and executes the endpoint +---@param request table The parsed JSON request +function BB_DISPATCHER.dispatch(request) + -- ================================================================= + -- TIER 1: Protocol Validation + -- ================================================================= + + -- Validate request has 'name' field + if not request.name or type(request.name) ~= "string" then + BB_DISPATCHER.send_error("Request missing 'name' field", errors.PROTO_MISSING_NAME) + return + end + + -- Validate request has 'arguments' field + if not request.arguments then + BB_DISPATCHER.send_error("Request missing 'arguments' field", errors.PROTO_MISSING_ARGUMENTS) + return + end + + -- Find endpoint + local endpoint = BB_DISPATCHER.endpoints[request.name] + if not endpoint then + BB_DISPATCHER.send_error("Unknown endpoint: " .. request.name, errors.PROTO_UNKNOWN_ENDPOINT) + return + end + + sendDebugMessage("Dispatching: " .. request.name, "BB.DISPATCHER") + + -- ================================================================= + -- TIER 2: Schema Validation + -- ================================================================= + + local valid, err_msg, err_code = Validator.validate(request.arguments, endpoint.schema) + if not valid then + -- When validation fails, err_msg and err_code are guaranteed to be non-nil + BB_DISPATCHER.send_error(err_msg or "Validation failed", err_code or "VALIDATION_ERROR") + 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 + BB_DISPATCHER.send_error( + "Endpoint '" + .. request.name + .. "' requires one of these states: " + .. table.concat(endpoint.requires_state, ", "), + errors.STATE_INVALID_STATE + ) + return + end + end + + -- ================================================================= + -- TIER 4: Execute Endpoint + -- ================================================================= + + -- Create send_response callback that uses Server.send_response + 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 + + -- Execute endpoint with error handling + local exec_success, exec_error = pcall(function() + endpoint.execute(request.arguments, send_response) + end) + + if not exec_success then + -- Endpoint threw an error + local error_message = tostring(exec_error) + + BB_DISPATCHER.send_error(error_message, errors.EXEC_INTERNAL_ERROR) + end +end From 430b43628d518caf025e448df7215e9a2cde0b59 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Tue, 11 Nov 2025 00:39:24 +0100 Subject: [PATCH 019/230] feat(lua): add validator module --- src/lua/core/validator.lua | 141 +++++++++++++++++++++++++++++++++++++ 1 file changed, 141 insertions(+) create mode 100644 src/lua/core/validator.lua diff --git a/src/lua/core/validator.lua b/src/lua/core/validator.lua new file mode 100644 index 0000000..1cdb051 --- /dev/null +++ b/src/lua/core/validator.lua @@ -0,0 +1,141 @@ +-- src/lua/core/validator.lua +-- Schema Validator for Endpoint Arguments +-- +-- Validates endpoint arguments against schema definitions using fail-fast validation. +-- Stops at the first error encountered and returns detailed error information. +-- +-- Validation Approach: +-- - No automatic defaults (endpoints handle optional arguments explicitly) +-- - Fail-fast (returns first validation error encountered) +-- - Type-strict (enforces exact type matches, no implicit conversions) +-- - Minimal schema (only type, required, items, description fields) +-- +-- Supported Types: +-- - string: Basic string type +-- - integer: Integer number (validated with math.floor check) +-- - array: Array of items (validated with sequential numeric indices) +-- +-- Range/Length Validation: +-- Min/max validation is NOT handled by the validator. Endpoints implement +-- their own dynamic validation based on game state (e.g., valid card indices, +-- valid stake ranges, etc.) + +---@type ErrorCodes +local errors = assert(SMODS.load_file("src/lua/utils/errors.lua"))() + +---@class SchemaField +---@field type "string"|"integer"|"array" The field type (only string, integer, and array supported) +---@field required boolean? Whether the field is required +---@field items "integer"? Type of array items (only "integer" supported, only for array type) +---@field description string Description of the field (required) + +---@class Validator +local Validator = {} + +--- Check if a value is an integer +---@param value any Value to check +---@return boolean is_integer +local function is_integer(value) + return type(value) == "number" and math.floor(value) == value +end + +--- Check if a value is an array (table with sequential numeric indices) +---@param value any Value to check +---@return boolean is_array +local function is_array(value) + if type(value) ~= "table" then + return false + end + local count = 0 + for k, _v in pairs(value) do + count = count + 1 + if type(k) ~= "number" or k ~= count then + return false + end + end + return true +end + +--- Validate a single field against its schema definition +---@param field_name string Name of the field being validated +---@param value any The value to validate +---@param field_schema SchemaField The schema definition for this field +---@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 + + -- Check type + if expected_type == "integer" then + if not is_integer(value) then + return false, "Field '" .. field_name .. "' must be an integer", errors.SCHEMA_INVALID_TYPE + end + elseif expected_type == "array" then + if not is_array(value) then + return false, "Field '" .. field_name .. "' must be an array", errors.SCHEMA_INVALID_TYPE + end + else + -- Standard Lua types: string, number, boolean, table + if type(value) ~= expected_type then + return false, "Field '" .. field_name .. "' must be of type " .. expected_type, errors.SCHEMA_INVALID_TYPE + end + end + + -- Validate array item types if specified (only for array type) + 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 = false + + if item_type == "integer" then + item_valid = is_integer(item) + else + item_valid = type(item) == item_type + end + + if not item_valid then + return false, + "Field '" .. field_name .. "' array item at index " .. (i - 1) .. " must be of type " .. item_type, + errors.SCHEMA_INVALID_ARRAY_ITEMS + end + end + end + + return true +end + +--- Validate arguments against a schema definition +---@param args table The arguments to validate +---@param schema table The schema definition +---@return boolean success +---@return string? error_message +---@return string? error_code +function Validator.validate(args, schema) + -- Ensure args is a table + if type(args) ~= "table" then + return false, "Arguments must be a table", errors.SCHEMA_INVALID_TYPE + end + + -- Validate each field in the schema + for field_name, field_schema in pairs(schema) do + local value = args[field_name] + + -- Check required fields + if field_schema.required and value == nil then + return false, "Missing required field '" .. field_name .. "'", errors.SCHEMA_MISSING_REQUIRED + end + + -- Validate field if present (skip optional fields that are nil) + 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 From c05f444ff18072b552fe75f1aba1998a748ffecd Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Tue, 11 Nov 2025 00:43:56 +0100 Subject: [PATCH 020/230] feat(lua): add test endpoints --- src/lua/endpoints/tests/echo.lua | 58 ++++++++++++++++++++++++++ src/lua/endpoints/tests/error.lua | 34 +++++++++++++++ src/lua/endpoints/tests/state.lua | 25 +++++++++++ src/lua/endpoints/tests/validation.lua | 58 ++++++++++++++++++++++++++ 4 files changed, 175 insertions(+) create mode 100644 src/lua/endpoints/tests/echo.lua create mode 100644 src/lua/endpoints/tests/error.lua create mode 100644 src/lua/endpoints/tests/state.lua create mode 100644 src/lua/endpoints/tests/validation.lua diff --git a/src/lua/endpoints/tests/echo.lua b/src/lua/endpoints/tests/echo.lua new file mode 100644 index 0000000..13c053f --- /dev/null +++ b/src/lua/endpoints/tests/echo.lua @@ -0,0 +1,58 @@ +-- src/lua/endpoints/tests/echo.lua +-- Test Endpoint for Dispatcher Testing +-- +-- Simplified endpoint for testing the dispatcher with the simplified validator + +---@type Endpoint +return { + name = "test_endpoint", + + description = "Test endpoint with schema for dispatcher testing", + + schema = { + -- Required string field + required_string = { + type = "string", + required = true, + description = "A required string field", + }, + + -- Optional string field + optional_string = { + type = "string", + description = "Optional string field", + }, + + -- Required integer field + required_integer = { + type = "integer", + required = true, + description = "Required integer field", + }, + + -- Optional integer field + optional_integer = { + type = "integer", + description = "Optional integer field", + }, + + -- Optional array of integers + optional_array_integers = { + type = "array", + items = "integer", + description = "Optional array of integers", + }, + }, + + requires_state = nil, -- Can be called from any state + + ---@param args table The validated arguments + ---@param send_response fun(response: table) Callback to send response + execute = function(args, send_response) + -- Echo back the received arguments + 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..db8b085 --- /dev/null +++ b/src/lua/endpoints/tests/error.lua @@ -0,0 +1,34 @@ +-- src/lua/endpoints/tests/error.lua +-- Test Endpoint that Throws Errors +-- +-- Used for testing TIER 4: Execution Error Handling + +---@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 table The arguments + ---@param send_response fun(response: table) Callback to send response + 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..0f21508 --- /dev/null +++ b/src/lua/endpoints/tests/state.lua @@ -0,0 +1,25 @@ +-- src/lua/endpoints/tests/state.lua +-- Test Endpoint with State Requirements +-- +-- Used for testing TIER 3: Game State Validation + +---@type Endpoint +return { + name = "test_state_endpoint", + + description = "Test endpoint that requires specific game states", + + schema = {}, -- No argument validation + + -- This endpoint can only be called from SPLASH or MENU states + requires_state = { "SPLASH", "MENU" }, + + ---@param _args table The arguments (empty) + ---@param send_response fun(response: table) Callback to send response + execute = function(_args, 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..c70115e --- /dev/null +++ b/src/lua/endpoints/tests/validation.lua @@ -0,0 +1,58 @@ +-- tests/lua/endpoints/test_validation.lua +-- Comprehensive Validation Test Endpoint +-- +-- Endpoint with schema for testing simplified validator capabilities: +-- - Type validation (string, integer, array) +-- - Required field validation +-- - Array item type validation (integer arrays only) + +---@type Endpoint +return { + name = "test_validation", + + description = "Comprehensive validation test endpoint for validator module testing", + + schema = { + -- Required field (only required field in the schema) + required_field = { + type = "string", + required = true, + description = "Required string field for basic validation testing", + }, + + -- Type validation fields + string_field = { + type = "string", + description = "Optional string field for type validation", + }, + + integer_field = { + type = "integer", + description = "Optional integer field for type validation", + }, + + array_field = { + type = "array", + description = "Optional array field for type validation", + }, + + -- Array item type validation + array_of_integers = { + type = "array", + items = "integer", + description = "Optional array that must contain only integers", + }, + }, + + requires_state = nil, -- Can be called from any state + + ---@param args table The validated arguments + ---@param send_response fun(response: table) Callback to send response + execute = function(args, send_response) + -- Simply return success with the received arguments + send_response({ + success = true, + received_args = args, + }) + end, +} From 0ec3b0ceb0e8d91f203779f5a40e7a6cebc4605f Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Tue, 11 Nov 2025 00:44:12 +0100 Subject: [PATCH 021/230] feat(lua): add health endpoint --- src/lua/endpoints/health.lua | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 src/lua/endpoints/health.lua diff --git a/src/lua/endpoints/health.lua b/src/lua/endpoints/health.lua new file mode 100644 index 0000000..b936b86 --- /dev/null +++ b/src/lua/endpoints/health.lua @@ -0,0 +1,32 @@ +-- src/lua/endpoints/health.lua +-- Health Check Endpoint +-- +-- Simple synchronous endpoint for connection testing and readiness checks +-- Returns server status and basic game information immediately + +---@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 string[]? Optional list of required game states +---@field execute fun(args: table, send_response: fun(response: table)) Execute function + +---@type Endpoint +return { + name = "health", + + description = "Health check endpoint for connection testing", + + schema = {}, -- No arguments required + + requires_state = nil, -- Can be called from any state + + ---@param _ table The arguments (empty for health check) + ---@param send_response fun(response: table) Callback to send response + execute = function(_, send_response) + -- Return simple status immediately (synchronous) + send_response({ + status = "ok", + }) + end, +} From 332979682cb54e7506016301ead815ebd33993d2 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Tue, 11 Nov 2025 00:49:14 +0100 Subject: [PATCH 022/230] chore(lua): add nil to return type --- src/lua/settings.lua | 1 + 1 file changed, 1 insertion(+) diff --git a/src/lua/settings.lua b/src/lua/settings.lua index 1d1472f..fcf21e4 100644 --- a/src/lua/settings.lua +++ b/src/lua/settings.lua @@ -204,6 +204,7 @@ end --- Initializes and applies all BalatroBot settings based on environment variables --- Orchestrates configuration of love.update, game settings, and optional features --- (headless, render-on-api, fast mode, audio) +---@return nil BB_SETTINGS.setup = function() configure_love_update() configure_settings() From 63088272b0687aa726d80c0f5e0f5fce8f2adfc6 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Tue, 11 Nov 2025 00:50:07 +0100 Subject: [PATCH 023/230] feat(lua): add debugger utils --- src/lua/utils/debugger.lua | 44 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 src/lua/utils/debugger.lua diff --git a/src/lua/utils/debugger.lua b/src/lua/utils/debugger.lua new file mode 100644 index 0000000..0cf20fb --- /dev/null +++ b/src/lua/utils/debugger.lua @@ -0,0 +1,44 @@ +-- 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") + +BB_DEBUG = { + -- Logger instance (set by setup if DebugPlus is available) + ---@type table? + 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") + + -- Register commands + -- ... +end From 58bf3fbe083266aa8770447929aa430515193e7d Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Tue, 11 Nov 2025 00:50:42 +0100 Subject: [PATCH 024/230] feat(lua): add error codes utils --- src/lua/utils/errors.lua | 64 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 src/lua/utils/errors.lua diff --git a/src/lua/utils/errors.lua b/src/lua/utils/errors.lua new file mode 100644 index 0000000..5519eaa --- /dev/null +++ b/src/lua/utils/errors.lua @@ -0,0 +1,64 @@ +-- src/lua/utils/errors.lua +-- Semantic Error Codes with Category Prefixes +-- +-- Error codes are organized by category for easier handling and debugging: +-- - PROTO_* : Protocol-level errors (malformed requests) +-- - SCHEMA_* : Schema validation errors (argument type/constraint errors) +-- - STATE_* : Game state validation errors (wrong state for action) +-- - GAME_* : Game logic errors (game rules violations) +-- - SEMANTIC_* : Endpoint-specific semantic errors +-- - EXEC_* : Execution errors (runtime failures) + +---@class ErrorCodes +---@field PROTO_INVALID_JSON string +---@field PROTO_MISSING_NAME string +---@field PROTO_MISSING_ARGUMENTS string +---@field PROTO_UNKNOWN_ENDPOINT string +---@field PROTO_PAYLOAD string +---@field SCHEMA_INVALID_TYPE string +---@field SCHEMA_MISSING_REQUIRED string +---@field SCHEMA_INVALID_ARRAY_ITEMS string +---@field STATE_INVALID_STATE string +---@field STATE_NOT_READY string +---@field EXEC_INTERNAL_ERROR string + +---@type ErrorCodes +return { + -- PROTO_* : Protocol-level errors (malformed requests) + PROTO_INVALID_JSON = "PROTO_INVALID_JSON", -- Invalid JSON syntax or non-object + PROTO_MISSING_NAME = "PROTO_MISSING_NAME", -- Request missing 'name' field + PROTO_MISSING_ARGUMENTS = "PROTO_MISSING_ARGUMENTS", -- Request missing 'arguments' field + PROTO_UNKNOWN_ENDPOINT = "PROTO_UNKNOWN_ENDPOINT", -- Unknown endpoint name + PROTO_PAYLOAD = "PROTO_PAYLOAD", -- Request exceeds 256 byte limit + + -- SCHEMA_* : Schema validation errors (argument type/constraint errors) + SCHEMA_INVALID_TYPE = "SCHEMA_INVALID_TYPE", -- Argument type mismatch + SCHEMA_MISSING_REQUIRED = "SCHEMA_MISSING_REQUIRED", -- Required argument missing + SCHEMA_INVALID_ARRAY_ITEMS = "SCHEMA_INVALID_ARRAY_ITEMS", -- Invalid array item type + + -- STATE_* : Game state validation errors (wrong state for action) + STATE_INVALID_STATE = "STATE_INVALID_STATE", -- Action not allowed in current state + STATE_NOT_READY = "STATE_NOT_READY", -- Server/dispatcher not initialized + + -- EXEC_* : Execution errors (runtime failures) + EXEC_INTERNAL_ERROR = "EXEC_INTERNAL_ERROR", -- Unexpected runtime error + + -- TODO: Define future error codes as needed: + -- + -- Here are some examples of future error codes: + -- PROTO_INCOMPLETE - No newline terminator + -- STATE_TRANSITION_FAILED - State transition error + -- GAME_INSUFFICIENT_FUNDS - Not enough money + -- GAME_NO_SPACE - No space in inventory/shop + -- GAME_ITEM_NOT_FOUND - Item/card not found + -- GAME_MISSING_OBJECT - Required game object missing + -- GAME_INVALID_ACTION - Invalid game action + -- SEMANTIC_CARD_NOT_SELLABLE - Card cannot be sold + -- SEMANTIC_CONSUMABLE_REQUIRES_TARGET - Consumable needs target + -- SEMANTIC_CONSUMABLE_NOT_USABLE - Consumable cannot be used + -- SEMANTIC_CANNOT_SKIP_BOSS - Boss blind cannot be skipped + -- SEMANTIC_NO_DISCARDS_LEFT - No discards remaining + -- SEMANTIC_UNIQUE_ITEM_OWNED - Already own unique item + -- EXEC_TIMEOUT - Request timeout + -- EXEC_DISCONNECT - Client disconnected +} From c40670b1cc0c62e37f7d3aed0bf939b48697c501 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Tue, 11 Nov 2025 00:52:51 +0100 Subject: [PATCH 025/230] fix: add --debug flag to balatro.py --- Makefile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Makefile b/Makefile index 56234e6..0f961f8 100644 --- a/Makefile +++ b/Makefile @@ -39,8 +39,8 @@ quality: lint typecheck format ## Run all code quality checks test: ## Run tests head-less @echo "$(YELLOW)Running tests...$(RESET)" - ./balatro.sh --fast --headless --ports 12346 - pytest + python balatro.py start --fast --debug + pytest tests/lua all: lint format typecheck test ## Run all code quality checks and tests @echo "$(GREEN)✓ All checks completed$(RESET)" From f253567e3f38d93b45bed5acc6b209fca9f3628e Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Tue, 11 Nov 2025 00:56:14 +0100 Subject: [PATCH 026/230] test(lua): add test for lua code --- tests/__init__.py | 0 tests/lua/conftest.py | 235 +++++++++-------- tests/lua/core/test_dispatcher.py | 404 +++++++++++++++++++++++++++++ tests/lua/core/test_server.py | 24 +- tests/lua/core/test_validator.py | 343 ++++++++++++++++++++++++ tests/lua/endpoints/__init__.py | 2 + tests/lua/endpoints/test_health.py | 174 +++++++++++++ 7 files changed, 1067 insertions(+), 115 deletions(-) create mode 100644 tests/__init__.py create mode 100644 tests/lua/core/test_dispatcher.py create mode 100644 tests/lua/core/test_validator.py create mode 100644 tests/lua/endpoints/test_health.py diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/lua/conftest.py b/tests/lua/conftest.py index abd6b40..14e7783 100644 --- a/tests/lua/conftest.py +++ b/tests/lua/conftest.py @@ -6,38 +6,48 @@ import pytest +# ============================================================================ +# Constants +# ============================================================================ + +HOST: str = "127.0.0.1" # Default host for Balatro server +PORT: int = 12346 # Default port for Balatro server BUFFER_SIZE: int = 65536 # 64KB buffer for TCP messages +@pytest.fixture(scope="session") +def host() -> str: + """Return the default Balatro server host.""" + return HOST + + @pytest.fixture -def client( - host: str = "127.0.0.1", - port: int = 12346, - timeout: float = 60, - buffer_size: int = BUFFER_SIZE, -) -> Generator[socket.socket, None, None]: +def client(host: str, port: int) -> Generator[socket.socket, None, None]: """Create a TCP socket client connected to Balatro game instance. Args: - host: The hostname or IP address of the Balatro game server (default: "127.0.0.1"). - port: The port number the Balatro game server is listening on (default: 12346). - timeout: Socket timeout in seconds for connection and operations (default: 60). - buffer_size: Size of the socket receive buffer (default: 65536, i.e. 64KB). + host: The hostname or IP address of the Balatro game server. + port: The port number the Balatro game server is listening on. Yields: A connected TCP socket for communicating with the game. """ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: - sock.settimeout(timeout) - sock.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, buffer_size) + sock.settimeout(60) # 60 second timeout for operations + sock.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, BUFFER_SIZE) sock.connect((host, port)) yield sock -@pytest.fixture +@pytest.fixture(scope="session") def port() -> int: """Return the default Balatro server port.""" - return 12346 + return PORT + + +# ============================================================================ +# Helper Functions +# ============================================================================ def api( @@ -48,7 +58,7 @@ def api( """Send an API call to the Balatro game and get the response. Args: - sock: The TCP socket connected to the game. + client: The TCP socket connected to the game. name: The name of the API function to call. arguments: Dictionary of arguments to pass to the API function (default: {}). @@ -62,96 +72,105 @@ def api( return gamestate -# import platform -# from pathlib import Path -# import shutil -# def assert_error_response( -# response, -# expected_error_text, -# expected_context_keys=None, -# expected_error_code=None, -# ): -# """ -# Helper function to assert the format and content of an error response. -# -# 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. -# -# Raises: -# AssertionError: If the response does not match the expected format or content. -# """ -# 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"] -# -# -# 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. -# -# Args: -# sock: Socket connection to the game. -# checkpoint_path: Path to the checkpoint .jkr file to load. -# -# Returns: -# Game state after loading the checkpoint. -# -# Raises: -# FileNotFoundError: If checkpoint file doesn't exist. -# RuntimeError: If loading the checkpoint fails. -# """ -# if not checkpoint_path.exists(): -# raise FileNotFoundError(f"Checkpoint file not found: {checkpoint_path}") -# -# # First, get the save directory from the game -# game_state = send_and_receive_api_message(sock, "get_save_info", {}) -# -# # Determine the Love2D save directory -# # On Linux with Steam, convert Windows paths -# -# 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" -# ) -# save_dir_str = str(linux_prefix) + "/" + save_dir_str[3:] -# -# save_dir = Path(save_dir_str) -# -# # 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) -# -# dest_path = test_dir / "save.jkr" -# shutil.copy2(checkpoint_path, dest_path) -# -# # 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} -# ) -# -# # Check for errors -# if "error" in game_state: -# raise RuntimeError(f"Failed to load checkpoint: {game_state['error']}") -# -# return game_state +def send_request(sock: socket.socket, name: str, arguments: dict[str, Any]) -> None: + """Send a JSON request to the server. + + Args: + sock: The TCP socket connected to the game. + name: The name of the endpoint to call. + arguments: Dictionary of arguments to pass to the endpoint. + """ + request = {"name": name, "arguments": arguments} + message = json.dumps(request) + "\n" + sock.sendall(message.encode()) + + +def receive_response(sock: socket.socket, timeout: float = 3.0) -> dict[str, Any]: + """Receive and parse JSON response from server. + + Args: + sock: The TCP socket connected to the game. + timeout: Socket timeout in seconds (default: 3.0). + + Returns: + The parsed JSON response as a dictionary. + """ + sock.settimeout(timeout) + response = sock.recv(BUFFER_SIZE) + decoded = response.decode() + + # Parse first complete message + first_newline = decoded.find("\n") + if first_newline != -1: + first_message = decoded[:first_newline] + else: + first_message = decoded.strip() + + return json.loads(first_message) + + +# ============================================================================ +# Assertion Helpers +# ============================================================================ + + +def assert_success_response(response: dict[str, Any]) -> None: + """Validate success response structure. + + Args: + response: The response dictionary to validate. + + Raises: + AssertionError: If the response is not a valid success response. + """ + assert "success" in response, "Success response must have 'success' field" + assert response["success"] is True, "'success' field must be True" + assert "error" not in response, "Success response should not have 'error' field" + assert "error_code" not in response, ( + "Success response should not have 'error_code' field" + ) + + +def assert_error_response( + response: dict[str, Any], + expected_error_code: str | None = None, + expected_message_contains: str | None = None, +) -> None: + """Validate error response structure and content. + + Args: + response: The response dictionary to validate. + expected_error_code: The expected error code (optional). + expected_message_contains: Substring expected in error message (optional). + + Raises: + AssertionError: If the response is not a valid error response or doesn't match expectations. + """ + assert "error" in response, "Error response must have 'error' field" + assert "error_code" in response, "Error response must have 'error_code' field" + + assert isinstance(response["error"], str), "'error' must be a string" + assert isinstance(response["error_code"], str), "'error_code' must be a string" + + if expected_error_code: + assert response["error_code"] == expected_error_code, ( + f"Expected error_code '{expected_error_code}', got '{response['error_code']}'" + ) + + if expected_message_contains: + assert expected_message_contains.lower() in response["error"].lower(), ( + f"Expected error message to contain '{expected_message_contains}', got '{response['error']}'" + ) + + +def assert_health_response(response: dict[str, Any]) -> None: + """Validate health response structure. + + Args: + response: The response dictionary to validate. + + Raises: + AssertionError: If the response is not a valid health response. + """ + assert "status" in response, "Health response must have 'status' field" + assert response["status"] == "ok", "Health response 'status' must be 'ok'" diff --git a/tests/lua/core/test_dispatcher.py b/tests/lua/core/test_dispatcher.py new file mode 100644 index 0000000..279465a --- /dev/null +++ b/tests/lua/core/test_dispatcher.py @@ -0,0 +1,404 @@ +""" +Integration tests for BB_DISPATCHER request routing and validation. + +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 json +import socket + +from tests.lua.conftest import BUFFER_SIZE + + +class TestDispatcherProtocolValidation: + """Tests for TIER 1: Protocol Validation. + + Tests verify that dispatcher correctly validates: + - Request has 'name' field (string) + - Request has 'arguments' field (table) + - Endpoint exists in registry + """ + + def test_missing_name_field(self, client: socket.socket) -> None: + """Test that requests without 'name' field are rejected.""" + request = json.dumps({"arguments": {}}) + "\n" + client.send(request.encode()) + + response = client.recv(BUFFER_SIZE).decode().strip() + data = json.loads(response) + + assert "error" in data + assert "error_code" in data + assert data["error_code"] == "PROTO_MISSING_NAME" + assert "name" in data["error"].lower() + + def test_invalid_name_type(self, client: socket.socket) -> None: + """Test that 'name' field must be a string.""" + request = json.dumps({"name": 123, "arguments": {}}) + "\n" + client.send(request.encode()) + + response = client.recv(BUFFER_SIZE).decode().strip() + data = json.loads(response) + + assert "error" in data + assert data["error_code"] == "PROTO_MISSING_NAME" + + def test_missing_arguments_field(self, client: socket.socket) -> None: + """Test that requests without 'arguments' field are rejected.""" + request = json.dumps({"name": "health"}) + "\n" + client.send(request.encode()) + + response = client.recv(BUFFER_SIZE).decode().strip() + data = json.loads(response) + + assert "error" in data + assert data["error_code"] == "PROTO_MISSING_ARGUMENTS" + assert "arguments" in data["error"].lower() + + def test_unknown_endpoint(self, client: socket.socket) -> None: + """Test that unknown endpoints are rejected.""" + request = json.dumps({"name": "nonexistent_endpoint", "arguments": {}}) + "\n" + client.send(request.encode()) + + response = client.recv(BUFFER_SIZE).decode().strip() + data = json.loads(response) + + assert "error" in data + assert data["error_code"] == "PROTO_UNKNOWN_ENDPOINT" + assert "nonexistent_endpoint" in data["error"] + + def test_valid_health_endpoint_request(self, client: socket.socket) -> None: + """Test that valid requests to health endpoint succeed.""" + request = json.dumps({"name": "health", "arguments": {}}) + "\n" + client.send(request.encode()) + + response = client.recv(BUFFER_SIZE).decode().strip() + data = json.loads(response) + + # Health endpoint should return success + assert "status" in data + assert data["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: socket.socket) -> None: + """Test that missing required fields are rejected.""" + # test_endpoint requires 'required_string' and 'required_integer' + request = ( + json.dumps( + { + "name": "test_endpoint", + "arguments": { + "required_integer": 50, + "required_enum": "option_a", + # Missing 'required_string' + }, + } + ) + + "\n" + ) + client.send(request.encode()) + + response = client.recv(BUFFER_SIZE).decode().strip() + data = json.loads(response) + + assert "error" in data + assert data["error_code"] == "SCHEMA_MISSING_REQUIRED" + assert "required_string" in data["error"].lower() + + def test_invalid_type_string_instead_of_integer( + self, client: socket.socket + ) -> None: + """Test that type validation rejects wrong types.""" + request = ( + json.dumps( + { + "name": "test_endpoint", + "arguments": { + "required_string": "valid_string", + "required_integer": "not_an_integer", # Should be integer + "required_enum": "option_a", + }, + } + ) + + "\n" + ) + client.send(request.encode()) + + response = client.recv(BUFFER_SIZE).decode().strip() + data = json.loads(response) + + assert "error" in data + assert data["error_code"] == "SCHEMA_INVALID_TYPE" + assert "required_integer" in data["error"].lower() + + def test_array_item_type_validation(self, client: socket.socket) -> None: + """Test that array items are validated for correct type.""" + request = ( + json.dumps( + { + "name": "test_endpoint", + "arguments": { + "required_string": "test", + "required_integer": 50, + "optional_array_integers": [ + 1, + 2, + "not_integer", + 4, + ], # Should be integers + }, + } + ) + + "\n" + ) + client.send(request.encode()) + + response = client.recv(BUFFER_SIZE).decode().strip() + data = json.loads(response) + + assert "error" in data + assert data["error_code"] == "SCHEMA_INVALID_ARRAY_ITEMS" + + def test_valid_request_with_all_fields(self, client: socket.socket) -> None: + """Test that valid requests with multiple fields pass validation.""" + request = ( + json.dumps( + { + "name": "test_endpoint", + "arguments": { + "required_string": "test", + "required_integer": 50, + "optional_string": "optional", + "optional_integer": 42, + "optional_array_integers": [1, 2, 3], + }, + } + ) + + "\n" + ) + client.send(request.encode()) + + response = client.recv(BUFFER_SIZE).decode().strip() + data = json.loads(response) + + # Should succeed and echo back + assert "success" in data + assert data["success"] is True + assert "received_args" in data + + def test_valid_request_with_only_required_fields( + self, client: socket.socket + ) -> None: + """Test that valid requests with only required fields pass validation.""" + request = ( + json.dumps( + { + "name": "test_endpoint", + "arguments": { + "required_string": "test", + "required_integer": 1, + "required_enum": "option_c", + }, + } + ) + + "\n" + ) + client.send(request.encode()) + + response = client.recv(BUFFER_SIZE).decode().strip() + data = json.loads(response) + + assert "success" in data + assert data["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: socket.socket) -> None: + """Test that endpoints with requires_state are validated.""" + # test_state_endpoint requires SPLASH or MENU state + request = json.dumps({"name": "test_state_endpoint", "arguments": {}}) + "\n" + client.send(request.encode()) + + response = client.recv(BUFFER_SIZE).decode().strip() + data = json.loads(response) + + # Response depends on current game state + # Either succeeds if in correct state, or fails with STATE_INVALID_STATE + if "error" in data: + assert data["error_code"] == "STATE_INVALID_STATE" + assert "requires" in data["error"].lower() + else: + assert "success" in data + assert data["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: socket.socket) -> None: + """Test that endpoints execute successfully with valid input.""" + request = ( + json.dumps( + { + "name": "test_endpoint", + "arguments": { + "required_string": "test", + "required_integer": 42, + "required_enum": "option_a", + }, + } + ) + + "\n" + ) + client.send(request.encode()) + + response = client.recv(BUFFER_SIZE).decode().strip() + data = json.loads(response) + + assert "success" in data + assert data["success"] is True + assert "received_args" in data + assert data["received_args"]["required_integer"] == 42 + + def test_execution_error_handling(self, client: socket.socket) -> None: + """Test that runtime errors are caught and returned properly.""" + request = ( + json.dumps( + { + "name": "test_error_endpoint", + "arguments": { + "error_type": "throw_error", + }, + } + ) + + "\n" + ) + client.send(request.encode()) + + response = client.recv(BUFFER_SIZE).decode().strip() + data = json.loads(response) + + assert "error" in data + assert data["error_code"] == "EXEC_INTERNAL_ERROR" + assert "Intentional test error" in data["error"] + + def test_execution_error_no_categorization(self, client: socket.socket) -> None: + """Test that all execution errors use EXEC_INTERNAL_ERROR.""" + request = ( + json.dumps( + { + "name": "test_error_endpoint", + "arguments": { + "error_type": "throw_error", + }, + } + ) + + "\n" + ) + client.send(request.encode()) + + response = client.recv(BUFFER_SIZE).decode().strip() + data = json.loads(response) + + # Should always be EXEC_INTERNAL_ERROR (no categorization) + assert data["error_code"] == "EXEC_INTERNAL_ERROR" + + def test_execution_success_when_no_error(self, client: socket.socket) -> None: + """Test that endpoints can execute successfully.""" + request = ( + json.dumps( + { + "name": "test_error_endpoint", + "arguments": { + "error_type": "success", + }, + } + ) + + "\n" + ) + client.send(request.encode()) + + response = client.recv(BUFFER_SIZE).decode().strip() + data = json.loads(response) + + assert "success" in data + assert data["success"] is True + + +class TestDispatcherEndpointRegistry: + """Tests for endpoint registration and discovery.""" + + def test_health_endpoint_is_registered(self, client: socket.socket) -> None: + """Test that the health endpoint is properly registered.""" + request = json.dumps({"name": "health", "arguments": {}}) + "\n" + client.send(request.encode()) + + response = client.recv(BUFFER_SIZE).decode().strip() + data = json.loads(response) + + assert "status" in data + assert data["status"] == "ok" + + def test_multiple_sequential_requests_to_same_endpoint( + self, client: socket.socket + ) -> None: + """Test that multiple requests to the same endpoint work.""" + for i in range(3): + request = json.dumps({"name": "health", "arguments": {}}) + "\n" + client.send(request.encode()) + + response = client.recv(BUFFER_SIZE).decode().strip() + data = json.loads(response) + + assert "status" in data + assert data["status"] == "ok" + + def test_requests_to_different_endpoints(self, client: socket.socket) -> None: + """Test that requests can be routed to different endpoints.""" + # Request to health endpoint + request1 = json.dumps({"name": "health", "arguments": {}}) + "\n" + client.send(request1.encode()) + response1 = client.recv(BUFFER_SIZE).decode().strip() + data1 = json.loads(response1) + assert "status" in data1 + + # Request to test_endpoint + request2 = ( + json.dumps( + { + "name": "test_endpoint", + "arguments": { + "required_string": "test", + "required_integer": 25, + "required_enum": "option_a", + }, + } + ) + + "\n" + ) + client.send(request2.encode()) + response2 = client.recv(BUFFER_SIZE).decode().strip() + data2 = json.loads(response2) + assert "success" in data2 diff --git a/tests/lua/core/test_server.py b/tests/lua/core/test_server.py index 1734a96..dd8242f 100644 --- a/tests/lua/core/test_server.py +++ b/tests/lua/core/test_server.py @@ -199,20 +199,30 @@ def test_message_too_large(self, client: socket.socket) -> None: assert "too large" in data["error"].lower() def test_pipelined_messages_rejected(self, client: socket.socket) -> None: - """Test that sending multiple messages at once is rejected.""" + """Test that sending multiple messages at once are processed sequentially.""" msg1 = json.dumps({"name": "health", "arguments": {}}) + "\n" msg2 = json.dumps({"name": "health", "arguments": {}}) + "\n" + # Send both messages in one packet (pipelining) client.send((msg1 + msg2).encode()) + # Server processes messages sequentially - we should get two responses response = client.recv(BUFFER_SIZE).decode().strip() - # May get multiple responses, take the first one - first_response = response.split("\n")[0] - data = json.loads(first_response) - assert "error" in data - assert "error_code" in data - assert data["error_code"] == "PROTO_PAYLOAD" + # We may get one or both responses depending on timing + # The important thing is no error occurred + lines = response.split("\n") + data1 = json.loads(lines[0]) + + # First response should be successful + assert "status" in data1 + assert data1["status"] == "ok" + + # If we got both in one recv, verify second is also good + if len(lines) > 1 and lines[1]: + data2 = json.loads(lines[1]) + assert "status" in data2 + assert data2["status"] == "ok" def test_invalid_json_syntax(self, client: socket.socket) -> None: """Test that malformed JSON is rejected.""" diff --git a/tests/lua/core/test_validator.py b/tests/lua/core/test_validator.py new file mode 100644 index 0000000..ca80a68 --- /dev/null +++ b/tests/lua/core/test_validator.py @@ -0,0 +1,343 @@ +# 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, array) +# - Required field validation +# - Array item type validation (integer arrays only) +# - Error codes and messages + +import socket + +from tests.lua.conftest import ( + assert_error_response, + assert_success_response, + receive_response, + send_request, +) + +# ============================================================================ +# Test: Type Validation +# ============================================================================ + + +class TestTypeValidation: + """Test type validation for all supported types.""" + + def test_valid_string_type(self, client: socket.socket) -> None: + """Test that valid string type passes validation.""" + send_request( + client, + "test_validation", + { + "required_field": "test", + "string_field": "hello", + }, + ) + response = receive_response(client) + assert_success_response(response) + + def test_invalid_string_type(self, client: socket.socket) -> None: + """Test that invalid string type fails validation.""" + send_request( + client, + "test_validation", + { + "required_field": "test", + "string_field": 123, # Should be string + }, + ) + response = receive_response(client) + assert_error_response( + response, + expected_error_code="SCHEMA_INVALID_TYPE", + expected_message_contains="string_field", + ) + + def test_valid_integer_type(self, client: socket.socket) -> None: + """Test that valid integer type passes validation.""" + send_request( + client, + "test_validation", + { + "required_field": "test", + "integer_field": 42, + }, + ) + response = receive_response(client) + assert_success_response(response) + + def test_invalid_integer_type_float(self, client: socket.socket) -> None: + """Test that float fails integer validation.""" + send_request( + client, + "test_validation", + { + "required_field": "test", + "integer_field": 42.5, # Should be integer + }, + ) + response = receive_response(client) + assert_error_response( + response, + expected_error_code="SCHEMA_INVALID_TYPE", + expected_message_contains="integer_field", + ) + + def test_invalid_integer_type_string(self, client: socket.socket) -> None: + """Test that string fails integer validation.""" + send_request( + client, + "test_validation", + { + "required_field": "test", + "integer_field": "42", + }, + ) + response = receive_response(client) + assert_error_response( + response, + expected_error_code="SCHEMA_INVALID_TYPE", + expected_message_contains="integer_field", + ) + + def test_valid_array_type(self, client: socket.socket) -> None: + """Test that valid array type passes validation.""" + send_request( + client, + "test_validation", + { + "required_field": "test", + "array_field": [1, 2, 3], + }, + ) + response = receive_response(client) + assert_success_response(response) + + def test_invalid_array_type_not_sequential(self, client: socket.socket) -> None: + """Test that non-sequential table fails array validation.""" + send_request( + client, + "test_validation", + { + "required_field": "test", + "array_field": {"key": "value"}, # Not an array + }, + ) + response = receive_response(client) + assert_error_response( + response, + expected_error_code="SCHEMA_INVALID_TYPE", + expected_message_contains="array_field", + ) + + def test_invalid_array_type_string(self, client: socket.socket) -> None: + """Test that string fails array validation.""" + send_request( + client, + "test_validation", + { + "required_field": "test", + "array_field": "not an array", + }, + ) + response = receive_response(client) + assert_error_response( + response, + expected_error_code="SCHEMA_INVALID_TYPE", + expected_message_contains="array_field", + ) + + +# ============================================================================ +# Test: Required Field Validation +# ============================================================================ + + +class TestRequiredFields: + """Test required field validation.""" + + def test_required_field_present(self, client: socket.socket) -> None: + """Test that request with required field passes.""" + send_request( + client, + "test_validation", + {"required_field": "present"}, + ) + response = receive_response(client) + assert_success_response(response) + + def test_required_field_missing(self, client: socket.socket) -> None: + """Test that request without required field fails.""" + send_request( + client, + "test_validation", + {}, # Missing required_field + ) + response = receive_response(client) + assert_error_response( + response, + expected_error_code="SCHEMA_MISSING_REQUIRED", + expected_message_contains="required_field", + ) + + def test_optional_field_missing(self, client: socket.socket) -> None: + """Test that missing optional fields are allowed.""" + send_request( + client, + "test_validation", + { + "required_field": "present", + # All other fields are optional + }, + ) + response = receive_response(client) + assert_success_response(response) + + +# ============================================================================ +# Test: Array Item Type Validation +# ============================================================================ + + +class TestArrayItemTypes: + """Test array item type validation.""" + + def test_array_of_integers_valid(self, client: socket.socket) -> None: + """Test that array of integers passes.""" + send_request( + client, + "test_validation", + { + "required_field": "test", + "array_of_integers": [1, 2, 3], + }, + ) + response = receive_response(client) + assert_success_response(response) + + def test_array_of_integers_invalid_float(self, client: socket.socket) -> None: + """Test that array with float items fails integer validation.""" + send_request( + client, + "test_validation", + { + "required_field": "test", + "array_of_integers": [1, 2.5, 3], + }, + ) + response = receive_response(client) + assert_error_response( + response, + expected_error_code="SCHEMA_INVALID_ARRAY_ITEMS", + expected_message_contains="array_of_integers", + ) + + def test_array_of_integers_invalid_string(self, client: socket.socket) -> None: + """Test that array with string items fails integer validation.""" + send_request( + client, + "test_validation", + { + "required_field": "test", + "array_of_integers": [1, "2", 3], + }, + ) + response = receive_response(client) + assert_error_response( + response, + expected_error_code="SCHEMA_INVALID_ARRAY_ITEMS", + expected_message_contains="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: socket.socket) -> None: + """Test that only the first error is returned when multiple errors exist.""" + send_request( + client, + "test_validation", + { + # Missing required_field (one error) + "string_field": 123, # Type error (another error) + "integer_field": "not an integer", # Type error (another error) + }, + ) + response = receive_response(client) + # 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_code"] in [ + "SCHEMA_MISSING_REQUIRED", + "SCHEMA_INVALID_TYPE", + ] + + +# ============================================================================ +# Test: Edge Cases +# ============================================================================ + + +class TestEdgeCases: + """Test edge cases and boundary conditions.""" + + def test_empty_arguments_with_only_required_field( + self, client: socket.socket + ) -> None: + """Test that arguments with only required field passes.""" + send_request( + client, + "test_validation", + {"required_field": "only this"}, + ) + response = receive_response(client) + assert_success_response(response) + + def test_all_fields_provided(self, client: socket.socket) -> None: + """Test request with multiple valid fields.""" + send_request( + client, + "test_validation", + { + "required_field": "test", + "string_field": "hello", + "integer_field": 42, + "array_field": [1, 2, 3], + "array_of_integers": [4, 5, 6], + }, + ) + response = receive_response(client) + assert_success_response(response) + + def test_empty_array_when_allowed(self, client: socket.socket) -> None: + """Test that empty array passes when no min constraint.""" + send_request( + client, + "test_validation", + { + "required_field": "test", + "array_field": [], + }, + ) + response = receive_response(client) + assert_success_response(response) + + def test_empty_string_when_allowed(self, client: socket.socket) -> None: + """Test that empty string passes when no min constraint.""" + send_request( + client, + "test_validation", + { + "required_field": "", # Empty but present + }, + ) + response = receive_response(client) + assert_success_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/test_health.py b/tests/lua/endpoints/test_health.py new file mode 100644 index 0000000..b3d721d --- /dev/null +++ b/tests/lua/endpoints/test_health.py @@ -0,0 +1,174 @@ +# 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 socket + +import pytest + +from tests.lua.conftest import assert_health_response, receive_response, send_request + +# ============================================================================ +# Test: Health Endpoint Basics +# ============================================================================ + + +class TestHealthEndpointBasics: + """Test basic health endpoint functionality.""" + + def test_health_check_succeeds(self, client: socket.socket) -> None: + """Test that health check returns status ok.""" + send_request(client, "health", {}) + response = receive_response(client) + assert_health_response(response) + + def test_health_check_with_empty_arguments(self, client: socket.socket) -> None: + """Test that health check works with empty arguments.""" + send_request(client, "health", {}) + response = receive_response(client) + assert_health_response(response) + + def test_health_check_ignores_extra_arguments(self, client: socket.socket) -> None: + """Test that health check ignores extra arguments.""" + send_request( + client, + "health", + { + "extra_field": "ignored", + "another_field": 123, + }, + ) + response = receive_response(client) + assert_health_response(response) + + +# ============================================================================ +# Test: Health Response Structure +# ============================================================================ + + +class TestHealthResponseStructure: + """Test health endpoint response structure.""" + + def test_response_has_status_field(self, client: socket.socket) -> None: + """Test that response contains status field.""" + send_request(client, "health", {}) + response = receive_response(client) + assert "status" in response + + def test_status_field_is_ok(self, client: socket.socket) -> None: + """Test that status field is 'ok'.""" + send_request(client, "health", {}) + response = receive_response(client) + assert response["status"] == "ok" + + def test_response_only_has_status_field(self, client: socket.socket) -> None: + """Test that response only contains the status field.""" + send_request(client, "health", {}) + response = receive_response(client) + assert list(response.keys()) == ["status"] + + +# ============================================================================ +# Test: Multiple Health Checks +# ============================================================================ + + +class TestMultipleHealthChecks: + """Test multiple sequential health checks.""" + + @pytest.mark.parametrize("iteration", range(10)) + def test_multiple_health_checks_succeed( + self, client: socket.socket, iteration: int + ) -> None: + """Test that multiple health checks all succeed.""" + send_request(client, "health", {}) + response = receive_response(client) + assert_health_response(response) + + def test_health_check_responses_consistent(self, client: socket.socket) -> None: + """Test that health check responses are consistent.""" + send_request(client, "health", {}) + response1 = receive_response(client) + + send_request(client, "health", {}) + response2 = receive_response(client) + + # Responses should be identical + assert response1 == response2 + assert response1["status"] == "ok" + assert response2["status"] == "ok" + + +# ============================================================================ +# Test: Health Check Edge Cases +# ============================================================================ + + +class TestHealthCheckEdgeCases: + """Test edge cases for health endpoint.""" + + def test_health_check_fast_response(self, client: socket.socket) -> None: + """Test that health check responds quickly (synchronous).""" + from time import time + + start = time() + send_request(client, "health", {}) + response = receive_response(client, timeout=1.0) + elapsed = time() - start + + # Should respond in less than 1 second (it's synchronous) + assert elapsed < 1.0 + assert_health_response(response) + + @pytest.mark.parametrize("iteration", range(5)) + def test_health_check_no_side_effects( + self, + client: socket.socket, + iteration: int, + ) -> None: + """Test that health check has no side effects.""" + send_request(client, "health", {}) + response = receive_response(client) + assert_health_response(response) + + +# ============================================================================ +# Test: Health Check Integration +# ============================================================================ + + +class TestHealthCheckIntegration: + """Test health check integration with other endpoints.""" + + def test_health_check_after_validation_endpoint( + self, client: socket.socket + ) -> None: + """Test health check after using validation endpoint.""" + # Use validation endpoint + send_request(client, "test_validation", {"required_field": "test"}) + validation_response = receive_response(client) + assert validation_response["success"] is True + + # Then health check + send_request(client, "health", {}) + health_response = receive_response(client) + assert_health_response(health_response) + + @pytest.mark.parametrize("iteration", range(5)) + def test_alternating_health_and_validation( + self, client: socket.socket, iteration: int + ) -> None: + """Test alternating between health and validation requests.""" + # Health check + send_request(client, "health", {}) + health_response = receive_response(client) + assert_health_response(health_response) + + # Validation endpoint + send_request(client, "test_validation", {"required_field": "test"}) + validation_response = receive_response(client) + assert validation_response["success"] is True From c0ae64fb2c95d57c995cfcaadaa8669cb12b7877 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Tue, 11 Nov 2025 00:56:58 +0100 Subject: [PATCH 027/230] feat: add balatro.py python script The script only works on macOS, but it's a good starting point for anyone who wants to run Balatro on Linux or Windows. --- balatro.py | 190 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 190 insertions(+) create mode 100755 balatro.py diff --git a/balatro.py b/balatro.py new file mode 100755 index 0000000..6d5de27 --- /dev/null +++ b/balatro.py @@ -0,0 +1,190 @@ +#!/usr/bin/env python3 +"""Minimal 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 kill(): + """Kill all running Balatro instances.""" + print("Killing all 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) + print("Done.") + + +def status(): + """Show running Balatro instances with ports.""" + # Find Balatro processes + result = subprocess.run( + ["ps", "aux"], + capture_output=True, + text=True, + ) + + balatro_pids = [] + for line in result.stdout.splitlines(): + if "Balatro.app" in line and "grep" not in line: + parts = line.split() + if len(parts) > 1: + balatro_pids.append(parts[1]) + + if not balatro_pids: + print("No Balatro instances running") + return + + # Find ports for each PID + for pid in balatro_pids: + result = subprocess.run( + ["lsof", "-Pan", "-p", pid, "-i", "TCP"], + capture_output=True, + text=True, + stderr=subprocess.DEVNULL, + ) + + port = None + for line in result.stdout.splitlines(): + if "LISTEN" in line: + parts = line.split() + for part in parts: + if ":" in part: + port = part.split(":")[-1] + break + if port: + break + + if port: + log_file = LOGS_DIR / f"balatro_{port}.log" + print(f"Port {port}, PID {pid}, Log: {log_file}") + + +def start(args): + """Start Balatro with given configuration.""" + # Kill existing instances first + subprocess.run(["pkill", "-f", "Balatro\\.app"], stderr=subprocess.DEVNULL) + time.sleep(1) + + # 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" + + # Open log file + log_file = LOGS_DIR / f"balatro_{args.port}.log" + with open(log_file, "w") as log: + # Start Balatro + process = subprocess.Popen( + [str(BALATRO_EXE)], + env=env, + stdout=log, + stderr=subprocess.STDOUT, + ) + + # Verify it started + time.sleep(5) + if process.poll() is not None: + print(f"ERROR: Balatro failed to start. Check {log_file}") + sys.exit(1) + + print(f"Port {args.port}, PID {process.pid}, Log: {log_file}") + + +def main(): + parser = argparse.ArgumentParser(description="Balatro launcher") + + subparsers = parser.add_subparsers(dest="command", help="Command to run") + + # Start command + start_parser = subparsers.add_parser( + "start", + help="Start Balatro (default)", + ) + start_parser.add_argument( + "--host", + default="127.0.0.1", + help="Server host (default: 127.0.0.1)", + ) + start_parser.add_argument( + "--port", + type=int, + default=12346, + help="Server port (default: 12346)", + ) + start_parser.add_argument( + "--headless", + action="store_true", + help="Run in headless mode", + ) + start_parser.add_argument( + "--fast", + action="store_true", + help="Run in fast mode", + ) + start_parser.add_argument( + "--render-on-api", + action="store_true", + help="Render only on API calls", + ) + start_parser.add_argument( + "--audio", + action="store_true", + help="Enable audio", + ) + start_parser.add_argument( + "--debug", + action="store_true", + help="Enable debug mode (requires DebugPlus mod, loads test endpoints)", + ) + + # Kill command + subparsers.add_parser( + "kill", + help="Kill all Balatro instances", + ) + + # Status command + subparsers.add_parser( + "status", + help="Show running instances", + ) + + args = parser.parse_args() + + # Execute command + if args.command == "kill": + kill() + elif args.command == "status": + status() + elif args.command == "start": + start(args) + + +if __name__ == "__main__": + main() From b90d77286f382e8d99f46c0a01caf4d7f6c60e00 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Tue, 11 Nov 2025 00:57:34 +0100 Subject: [PATCH 028/230] feat: update balatrobot.lua --- balatrobot.lua | 50 +++++++++++++++++++++++++++++++++++++------------- 1 file changed, 37 insertions(+), 13 deletions(-) diff --git a/balatrobot.lua b/balatrobot.lua index afa0774..c3e9648 100644 --- a/balatrobot.lua +++ b/balatrobot.lua @@ -1,17 +1,41 @@ --- 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 = { + "src/lua/endpoints/health.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 + +-- 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 +local love_update = love.update +love.update = function(dt) ---@diagnostic disable-line: duplicate-set-field + love_update(dt) + BB_SERVER.update(BB_DISPATCHER) +end + +sendInfoMessage("BalatroBot loaded - version " .. SMODS.current_mod.version, "BB.BALATROBOT") From f550f4eb2374e7952634d9c54718a6fcd1d7c428 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Tue, 11 Nov 2025 00:57:49 +0100 Subject: [PATCH 029/230] chore: add pytest-rerunfailures --- pyproject.toml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 8933df4..ae09cca 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,6 +38,9 @@ lint.task-tags = ["FIX", "TODO", "HACK", "WARN", "PERF", "NOTE", "TEST"] [tool.pyright] typeCheckingMode = "basic" +[tool.pytest.ini_options] +addopts = "--reruns 5" + [dependency-groups] dev = [ "basedpyright>=1.29.5", @@ -49,6 +52,7 @@ dev = [ "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", ] From 9f9ef7dd0eead5364adcdea613f4fdb98e7bfa0f Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Tue, 11 Nov 2025 00:57:59 +0100 Subject: [PATCH 030/230] chore: update uv.lock --- uv.lock | 529 +++++++++++++++++++++++++++++--------------------------- 1 file changed, 272 insertions(+), 257 deletions(-) diff --git a/uv.lock b/uv.lock index c43a35a..8ba7e0f 100644 --- a/uv.lock +++ b/uv.lock @@ -1,37 +1,37 @@ version = 1 -revision = 3 +revision = 2 requires-python = ">=3.13" [[package]] name = "annotated-types" version = "0.7.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" } +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" } 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/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" }, ] [[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]] @@ -53,6 +53,7 @@ dev = [ { name = "mkdocstrings", extra = ["python"] }, { name = "pytest" }, { name = "pytest-cov" }, + { name = "pytest-rerunfailures" }, { name = "pytest-xdist", extra = ["psutil"] }, { name = "ruff" }, ] @@ -71,6 +72,7 @@ dev = [ { 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" }, ] @@ -82,9 +84,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 +97,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,49 +140,49 @@ 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" }, +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]] @@ -190,18 +192,18 @@ 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" } +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" }, + { 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" }, ] [[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,9 +213,9 @@ 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]] @@ -223,27 +225,27 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama" }, ] -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/a9/3e/5aa9a61f7c3c47b0b52a1d930302992229d191bf4bc76447b324b731510a/griffe-1.7.3.tar.gz", hash = "sha256:52ee893c6a3a968b639ace8015bec9d36594961e156e23315c8e8e51401fa50b", size = 395137, upload_time = "2025-04-23T11:29:09.147Z" } 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/58/c6/5c20af38c2a57c15d87f7f38bee77d63c1d2a3689f74fefaf35915dd12b2/griffe-1.7.3-py3-none-any.whl", hash = "sha256:c6b3ee30c2f0f17f30bcdef5068d6ab7a2a4f1b8bf1a3e74b56fffd21e1c5f75", size = 129303, upload_time = "2025-04-23T11:29:07.145Z" }, ] [[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 +255,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 +276,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 +289,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 +329,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 +344,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 +359,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 +371,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 +384,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 +396,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,9 +438,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" }, + { 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]] @@ -450,9 +452,9 @@ dependencies = [ { 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" } +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/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" }, ] [[package]] @@ -464,9 +466,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 +481,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,18 +503,18 @@ 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" } +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" }, + { 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]] @@ -527,9 +529,9 @@ dependencies = [ { 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" } +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" }, + { 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] @@ -546,103 +548,103 @@ dependencies = [ { 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/bf/ed/b886f8c714fd7cccc39b79646b627dbea84cd95c46be43459ef46852caf0/mkdocstrings_python-1.16.12.tar.gz", hash = "sha256:9b9eaa066e0024342d433e332a41095c4e429937024945fea511afe58f63175d", size = 206065, upload_time = "2025-06-03T12:52:49.276Z" } 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/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" }, ] [[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" } +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" }, + { 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/4a/88/39c83c35d5e97cc203e9e77a4f93bf87ec89cf6a22ac4818fdcc65d66584/orderly_set-5.5.0.tar.gz", hash = "sha256:e87185c8e4d8afa64e7f8160ee2c542a475b738bc891dc3f58102e654125e6ce", size = 27414, upload_time = "2025-07-10T20:10:55.885Z" } 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/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" }, ] [[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" }, + { 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]] @@ -655,9 +657,9 @@ dependencies = [ { 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" } +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" }, + { 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]] @@ -667,34 +669,34 @@ 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" } +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/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" }, ] [[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 +707,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 +723,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 +737,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" }, + { 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/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 +763,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 +780,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 +809,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,61 +824,61 @@ 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" 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" } +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/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/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]] @@ -873,46 +888,46 @@ 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/f8/b1/0c11f5058406b3af7609f121aaa6b609744687f1d158b3c3a5bf4cc94238/typing_inspection-0.4.1.tar.gz", hash = "sha256:6ae134cc0203c33377d43188d4064e9b357dba58cff3185f22924610e70a9d28", size = 75726, upload_time = "2025-05-21T18:55:23.885Z" } 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/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" }, ] [[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" }, ] From 11d9e31b985d315a04aecf072ca155da00bd1fff Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Tue, 11 Nov 2025 00:58:25 +0100 Subject: [PATCH 031/230] chore: ignore old files --- .gitignore | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.gitignore b/.gitignore index 70a10f7..7521a50 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,7 @@ dump coverage.xml .coverage balatro +src/lua_oldish +tests/lua_old +balatrobot_oldish.lua +balatro_oldish.sh From 23e4fc4079d210f71f6c6bdbfdc8b2dcd606430f Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Tue, 11 Nov 2025 12:10:49 +0100 Subject: [PATCH 032/230] chore: add aerospace cmd to Makefile test target This is something that I'm using in my own dev setup. It shouldn't affect anyone else. --- Makefile | 1 + 1 file changed, 1 insertion(+) diff --git a/Makefile b/Makefile index 0f961f8..a865b08 100644 --- a/Makefile +++ b/Makefile @@ -40,6 +40,7 @@ quality: lint typecheck format ## Run all code quality checks test: ## Run tests head-less @echo "$(YELLOW)Running tests...$(RESET)" python balatro.py start --fast --debug + @command -v aerospace >/dev/null 2>&1 && aerospace workspace 3 pytest tests/lua all: lint format typecheck test ## Run all code quality checks and tests From 557fab14edcea094f64a8b8fa0643f819db13cf5 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Thu, 13 Nov 2025 14:04:20 +0100 Subject: [PATCH 033/230] fix(lua): add missing test endpoint --- src/lua/endpoints/tests/endpoint.lua | 58 ++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 src/lua/endpoints/tests/endpoint.lua diff --git a/src/lua/endpoints/tests/endpoint.lua b/src/lua/endpoints/tests/endpoint.lua new file mode 100644 index 0000000..ca81f80 --- /dev/null +++ b/src/lua/endpoints/tests/endpoint.lua @@ -0,0 +1,58 @@ +-- tests/lua/endpoints/tests/endpoint.lua +-- Test Endpoint for Dispatcher Testing +-- +-- Simplified endpoint for testing the dispatcher with the simplified validator + +---@type Endpoint +return { + name = "test_endpoint", + + description = "Test endpoint with schema for dispatcher testing", + + schema = { + -- Required string field + required_string = { + type = "string", + required = true, + description = "A required string field", + }, + + -- Optional string field + optional_string = { + type = "string", + description = "Optional string field", + }, + + -- Required integer field + required_integer = { + type = "integer", + required = true, + description = "Required integer field", + }, + + -- Optional integer field + optional_integer = { + type = "integer", + description = "Optional integer field", + }, + + -- Optional array of integers + optional_array_integers = { + type = "array", + items = "integer", + description = "Optional array of integers", + }, + }, + + requires_state = nil, -- Can be called from any state + + ---@param args table The validated arguments + ---@param send_response fun(response: table) Callback to send response + execute = function(args, send_response) + -- Echo back the received arguments + send_response({ + success = true, + received_args = args, + }) + end, +} From c3fe20d6b7350bdffad7386bfabbcad20dc89de4 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Thu, 13 Nov 2025 14:07:56 +0100 Subject: [PATCH 034/230] chore: add fixture generation to test target in Makefile --- Makefile | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Makefile b/Makefile index a865b08..29d5ff6 100644 --- a/Makefile +++ b/Makefile @@ -38,8 +38,11 @@ quality: lint typecheck format ## Run all code quality checks @echo "$(GREEN)✓ All checks completed$(RESET)" test: ## Run tests head-less - @echo "$(YELLOW)Running tests...$(RESET)" + @echo "$(YELLOW)Starting Balatro...$(RESET)" python balatro.py start --fast --debug + @echo "$(YELLOW)Generating fixtures...$(RESET)" + python tests/fixtures/generate.py + @echo "$(YELLOW)Running tests...$(RESET)" @command -v aerospace >/dev/null 2>&1 && aerospace workspace 3 pytest tests/lua From 69ffabdb94d5af7673ec5bed65dec2f9ed216dbb Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Thu, 13 Nov 2025 14:20:25 +0100 Subject: [PATCH 035/230] test: add generate.py to generate fixtures for testing --- tests/fixtures/README.md | 61 ++++++++++++++++ tests/fixtures/generate.py | 144 +++++++++++++++++++++++++++++++++++++ 2 files changed, 205 insertions(+) create mode 100644 tests/fixtures/README.md create mode 100644 tests/fixtures/generate.py diff --git a/tests/fixtures/README.md b/tests/fixtures/README.md new file mode 100644 index 0000000..ec3a453 --- /dev/null +++ b/tests/fixtures/README.md @@ -0,0 +1,61 @@ +# Test Fixtures + +This directory contains test fixture files (`.jkr` save files) used for testing the save and load endpoints. + +Fixtures are organized hierarchically by endpoint: + +``` +tests/fixtures/ +├── save/ # Fixtures for save endpoint tests +├── load/ # Fixtures for load endpoint tests +├── generate.py # Script to generate all fixtures +└── README.md +``` + +## Generating Fixtures + +### Prerequisites + +1. Start Balatro with the BalatroBot mod loaded +2. Make sure you're in an appropriate game state for the fixtures you need + +### Generate All Fixtures + +```bash +python tests/fixtures/generate.py +``` + +The script will automatically connect to Balatro on localhost:12346 and generate all required fixtures. + +## Adding New Fixtures + +To add new fixtures: + +1. Create the appropriate directory structure under the endpoint category +2. Update `generate.py` to include the new fixture generation logic +3. Add fixture descriptions to this README + +## Usage in Tests + +Fixtures are accessed using the `get_fixture_path()` helper function: + +```python +from tests.lua.conftest import get_fixture_path + +def test_example(client): + fixture_path = get_fixture_path("load", "start.jkr") + send_request(client, "load", {"path": str(fixture_path)}) + response = receive_response(client) + assert response["success"] is True +``` + +## Current Fixtures + +### Save Endpoint Tests (`save/`) + +- `start.jkr` - Valid save file from initial game state + +### Load Endpoint Tests (`load/`) + +- `start.jkr` - Valid save file from initial game state +- `corrupted.jkr` - Intentionally corrupted save file for error testing diff --git a/tests/fixtures/generate.py b/tests/fixtures/generate.py new file mode 100644 index 0000000..f9a8a50 --- /dev/null +++ b/tests/fixtures/generate.py @@ -0,0 +1,144 @@ +#!/usr/bin/env python3 +"""Generate test fixture files for save/load endpoint testing. + +This script connects to a running Balatro instance and uses the save endpoint +to generate .jkr fixture files for testing. It also creates corrupted files +for testing error handling. + +Fixtures are organized by endpoint: +- save/start.jkr - Used by save tests to get into run state +- load/start.jkr - Used by load tests to test loading +- load/corrupted.jkr - Used by load tests to test error handling + +Usage: + python generate.py +""" + +import json +import socket +from pathlib import Path + +FIXTURES_DIR = Path(__file__).parent +HOST = "127.0.0.1" +PORT = 12346 +BUFFER_SIZE = 65536 + + +def send_request(sock: socket.socket, name: str, arguments: dict) -> None: + """Send a JSON request to the Balatro server.""" + request = {"name": name, "arguments": arguments} + message = json.dumps(request) + "\n" + sock.sendall(message.encode()) + + +def receive_response(sock: socket.socket, timeout: float = 3.0) -> dict: + """Receive and parse JSON response from server.""" + sock.settimeout(timeout) + response = sock.recv(BUFFER_SIZE) + decoded = response.decode() + first_newline = decoded.find("\n") + if first_newline != -1: + first_message = decoded[:first_newline] + else: + first_message = decoded.strip() + return json.loads(first_message) + + +def generate_start_fixtures() -> None: + """Generate start.jkr fixtures for both save and load endpoints. + + Creates identical start.jkr files in both save/ and load/ directories + from the current game state. This should be run when the game is in + an initial state (e.g., early in a run). + """ + save_fixture = FIXTURES_DIR / "save" / "start.jkr" + load_fixture = FIXTURES_DIR / "load" / "start.jkr" + + print(f"Generating start.jkr fixtures...") + + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.settimeout(10) + sock.connect((HOST, PORT)) + + # Save to save/ directory + send_request(sock, "save", {"path": str(save_fixture)}) + response = receive_response(sock) + + if "error" in response: + print( + f" Error: {response['error']} ({response.get('error_code', 'UNKNOWN')})" + ) + print(" Make sure you're in an active run before generating fixtures") + return + + if response.get("success"): + print(f" Generated {save_fixture}") + print(f" File size: {save_fixture.stat().st_size} bytes") + + # Copy to load/ directory + load_fixture.write_bytes(save_fixture.read_bytes()) + print(f" Generated {load_fixture}") + print(f" File size: {load_fixture.stat().st_size} bytes") + + # Validate by loading it back + send_request(sock, "load", {"path": str(load_fixture)}) + load_response = receive_response(sock) + + if load_response.get("success"): + print(f" Validated: fixtures load successfully") + else: + print(f" Warning: fixtures generated but failed to load") + print(f" Error: {load_response.get('error', 'Unknown error')}") + else: + print(f" Failed to generate fixtures") + + +def generate_corrupted() -> None: + """Generate corrupted.jkr fixture for error testing. + + Creates an intentionally corrupted .jkr file in load/ directory to test + EXEC_INVALID_SAVE_FORMAT error handling in the load endpoint. + """ + fixture_path = FIXTURES_DIR / "load" / "corrupted.jkr" + print(f"Generating {fixture_path}...") + + # Write invalid/truncated data that won't decompress correctly + corrupted_data = b"CORRUPTED_SAVE_FILE_FOR_TESTING\x00\x01\x02" + + fixture_path.write_bytes(corrupted_data) + print(f" Generated {fixture_path}") + print(f" File size: {fixture_path.stat().st_size} bytes") + print(f" This file is intentionally corrupted for error testing") + + +def main() -> None: + """Main entry point for fixture generation.""" + print("BalatroBot Fixture Generator") + print(f"Connecting to {HOST}:{PORT}") + print() + + try: + generate_start_fixtures() + print() + generate_corrupted() + print() + + print("Fixture generation complete!") + print(f"Fixtures organized in: {FIXTURES_DIR}/") + print(" - save/start.jkr") + print(" - load/start.jkr") + print(" - load/corrupted.jkr") + + except ConnectionRefusedError: + print(f"Error: Could not connect to Balatro at {HOST}:{PORT}") + print("Make sure Balatro is running with BalatroBot mod loaded") + return 1 + except Exception as e: + print(f"Unexpected error: {e}") + return 1 + + return 0 + + +if __name__ == "__main__": + exit(main()) From b784ffe98978450b951777276b6deba08caab2c0 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Thu, 13 Nov 2025 14:22:18 +0100 Subject: [PATCH 036/230] feat(lua): add errors for load and save --- src/lua/utils/errors.lua | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/lua/utils/errors.lua b/src/lua/utils/errors.lua index 5519eaa..83b4982 100644 --- a/src/lua/utils/errors.lua +++ b/src/lua/utils/errors.lua @@ -20,7 +20,13 @@ ---@field SCHEMA_INVALID_ARRAY_ITEMS string ---@field STATE_INVALID_STATE string ---@field STATE_NOT_READY string +---@field GAME_NOT_IN_RUN string +---@field GAME_INVALID_STATE string ---@field EXEC_INTERNAL_ERROR string +---@field EXEC_FILE_NOT_FOUND string +---@field EXEC_FILE_READ_ERROR string +---@field EXEC_FILE_WRITE_ERROR string +---@field EXEC_INVALID_SAVE_FORMAT string ---@type ErrorCodes return { @@ -40,8 +46,16 @@ return { STATE_INVALID_STATE = "STATE_INVALID_STATE", -- Action not allowed in current state STATE_NOT_READY = "STATE_NOT_READY", -- Server/dispatcher not initialized + -- GAME_* : Game logic errors (game rules violations) + GAME_NOT_IN_RUN = "GAME_NOT_IN_RUN", -- Action requires active run + GAME_INVALID_STATE = "GAME_INVALID_STATE", -- Action not allowed in current game state + -- EXEC_* : Execution errors (runtime failures) EXEC_INTERNAL_ERROR = "EXEC_INTERNAL_ERROR", -- Unexpected runtime error + EXEC_FILE_NOT_FOUND = "EXEC_FILE_NOT_FOUND", -- File does not exist + EXEC_FILE_READ_ERROR = "EXEC_FILE_READ_ERROR", -- Failed to read file + EXEC_FILE_WRITE_ERROR = "EXEC_FILE_WRITE_ERROR", -- Failed to write file + EXEC_INVALID_SAVE_FORMAT = "EXEC_INVALID_SAVE_FORMAT", -- Invalid save file format -- TODO: Define future error codes as needed: -- From 376ac381712aa28e3da300f00542fad7101166d3 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Thu, 13 Nov 2025 14:22:49 +0100 Subject: [PATCH 037/230] test(lua): add conftest functions for load and save tests --- tests/lua/conftest.py | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/tests/lua/conftest.py b/tests/lua/conftest.py index 14e7783..e9ae45f 100644 --- a/tests/lua/conftest.py +++ b/tests/lua/conftest.py @@ -2,6 +2,9 @@ import json import socket +import tempfile +import uuid +from pathlib import Path from typing import Any, Generator import pytest @@ -109,6 +112,30 @@ def receive_response(sock: socket.socket, timeout: float = 3.0) -> dict[str, Any return json.loads(first_message) +def get_fixture_path(endpoint: str, fixture_name: str) -> Path: + """Get path to a test fixture file. + + Args: + endpoint: The endpoint directory (e.g., "save", "load"). + fixture_name: Name of the fixture file (e.g., "start.jkr"). + + Returns: + Path to the fixture file in tests/fixtures//. + """ + fixtures_dir = Path(__file__).parent.parent / "fixtures" + return fixtures_dir / endpoint / fixture_name + + +def create_temp_save_path() -> Path: + """Create a temporary path for save files. + + 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" + + # ============================================================================ # Assertion Helpers # ============================================================================ From 5da4602f796bbe3392f6ad397154a98c1453dc4f Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Thu, 13 Nov 2025 14:23:08 +0100 Subject: [PATCH 038/230] chore: add smods and jkr files to gitignore --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 7521a50..366f968 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,5 @@ src/lua_oldish tests/lua_old balatrobot_oldish.lua balatro_oldish.sh +*.jkr +smods From e9ba224ba69001f7610dfd7fe667e6192c22f1e1 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Thu, 13 Nov 2025 14:23:28 +0100 Subject: [PATCH 039/230] feat(lua): add load and save endpoints --- balatrobot.lua | 2 ++ 1 file changed, 2 insertions(+) diff --git a/balatrobot.lua b/balatrobot.lua index c3e9648..cf0f655 100644 --- a/balatrobot.lua +++ b/balatrobot.lua @@ -7,6 +7,8 @@ BB_SETTINGS.setup() -- Endpoints for the BalatroBot API BB_ENDPOINTS = { "src/lua/endpoints/health.lua", + "src/lua/endpoints/save.lua", + "src/lua/endpoints/load.lua", -- If debug mode is enabled, debugger.lua will load test endpoints } From 3a9ed476efa4c9fde85269048fa26c4c9336d176 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Fri, 14 Nov 2025 14:43:52 +0100 Subject: [PATCH 040/230] chore: move fixtures generation to a separate target --- Makefile | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/Makefile b/Makefile index 29d5ff6..795e364 100644 --- a/Makefile +++ b/Makefile @@ -37,13 +37,15 @@ typecheck: ## Run type checker quality: lint typecheck format ## Run all code quality checks @echo "$(GREEN)✓ All checks completed$(RESET)" +fixtures: ## Generate fixtures + @echo "$(YELLOW)Generating fixtures...$(RESET)" + python tests/fixtures/generate.py + test: ## Run tests head-less @echo "$(YELLOW)Starting Balatro...$(RESET)" python balatro.py start --fast --debug - @echo "$(YELLOW)Generating fixtures...$(RESET)" - python tests/fixtures/generate.py - @echo "$(YELLOW)Running tests...$(RESET)" @command -v aerospace >/dev/null 2>&1 && aerospace workspace 3 + @echo "$(YELLOW)Running tests...$(RESET)" pytest tests/lua all: lint format typecheck test ## Run all code quality checks and tests From f49a031f218ebef26dc5f9fa0b29b941ff3d3ff3 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Sat, 15 Nov 2025 15:07:39 +0100 Subject: [PATCH 041/230] chore: move aerospace cmd from Makefile to balatro.py chore: move aerospace cmd from Makefile to balatro.py --- Makefile | 1 - balatro.py | 7 ++++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/Makefile b/Makefile index 795e364..fa95b6d 100644 --- a/Makefile +++ b/Makefile @@ -44,7 +44,6 @@ fixtures: ## Generate fixtures test: ## Run tests head-less @echo "$(YELLOW)Starting Balatro...$(RESET)" python balatro.py start --fast --debug - @command -v aerospace >/dev/null 2>&1 && aerospace workspace 3 @echo "$(YELLOW)Running tests...$(RESET)" pytest tests/lua diff --git a/balatro.py b/balatro.py index 6d5de27..df67f4c 100755 --- a/balatro.py +++ b/balatro.py @@ -108,11 +108,16 @@ def start(args): ) # Verify it started - time.sleep(5) + time.sleep(4) if process.poll() is not None: print(f"ERROR: Balatro failed to start. Check {log_file}") sys.exit(1) + subprocess.Popen( + "command -v aerospace >/dev/null 2>&1 && aerospace workspace 3", + shell=True, + ) + print(f"Port {args.port}, PID {process.pid}, Log: {log_file}") From 62724c2d8e0dc46db0fa9984f2610511572fd717 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Sun, 16 Nov 2025 14:32:57 +0100 Subject: [PATCH 042/230] chore: add stylua formatter --- Makefile | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Makefile b/Makefile index fa95b6d..3407eba 100644 --- a/Makefile +++ b/Makefile @@ -29,6 +29,8 @@ format: ## Run ruff and mdformat formatters ruff format . @echo "$(YELLOW)Running mdformat formatter...$(RESET)" mdformat ./docs README.md CLAUDE.md + @echo "$(YELLOW)Running stylua formatter...$(RESET)" + stylua src/lua typecheck: ## Run type checker @echo "$(YELLOW)Running type checker...$(RESET)" From 6be3497bb96c16dccb59829867b306b112dd399b Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Mon, 17 Nov 2025 12:38:57 +0100 Subject: [PATCH 043/230] feat: add option to disable shaders with env var BALATROBOT_NO_SHADERS --- balatro.py | 7 +++++++ src/lua/settings.lua | 21 +++++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/balatro.py b/balatro.py index df67f4c..270317a 100755 --- a/balatro.py +++ b/balatro.py @@ -95,6 +95,8 @@ def start(args): env["BALATROBOT_AUDIO"] = "1" if args.debug: env["BALATROBOT_DEBUG"] = "1" + if args.no_shaders: + env["BALATROBOT_NO_SHADERS"] = "1" # Open log file log_file = LOGS_DIR / f"balatro_{args.port}.log" @@ -167,6 +169,11 @@ def main(): action="store_true", help="Enable debug mode (requires DebugPlus mod, loads test endpoints)", ) + start_parser.add_argument( + "--no-shaders", + action="store_true", + help="Disable all shaders for better performance", + ) # Kill command subparsers.add_parser( diff --git a/src/lua/settings.lua b/src/lua/settings.lua index fcf21e4..a2544be 100644 --- a/src/lua/settings.lua +++ b/src/lua/settings.lua @@ -21,6 +21,9 @@ BalatroBot configure settings in Balatro using the following environment variabl - BALATROBOT_DEBUG: whether enable debug mode. It requires DebugPlus mod to be running. 1 for actiavate the debug mode, 0 for no debug (default: 0) + + - BALATROBOT_NO_SHADERS: whether to disable all shaders for better performance. + 1 for disable shaders, 0 for enable shaders (default: 0) ]] ---@diagnostic disable: duplicate-set-field @@ -41,6 +44,8 @@ BB_SETTINGS = { audio = os.getenv("BALATROBOT_AUDIO") == "1" or false, ---@type boolean debug = os.getenv("BALATROBOT_DEBUG") == "1" or false, + ---@type boolean + no_shaders = os.getenv("BALATROBOT_NO_SHADERS") == "1" or false, } -- Global flag to trigger rendering (used by render_on_api) @@ -190,6 +195,18 @@ local function configure_fast() G.F_VERBOSE = false end +--- Disables all shaders by overriding love.graphics.setShader to always pass nil +--- This improves performance by bypassing shader compilation and rendering +--- Disabling shaders cause visual glitches. Use at your own risk. +---@return nil +local function configure_no_shaders() + local love_graphics_setShader = love.graphics.setShader + love.graphics.setShader = function(_) + return love_graphics_setShader(nil) + end + sendDebugMessage("Disabled all shaders", "BB.SETTINGS") +end + --- Enables audio by setting volume levels and enabling sound thread ---@return nil local function configure_audio() @@ -226,6 +243,10 @@ BB_SETTINGS.setup = function() configure_fast() end + if BB_SETTINGS.no_shaders then + configure_no_shaders() + end + if BB_SETTINGS.audio then configure_audio() end From 652214a75424c7318803d682fb49ec2837ba6586 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Mon, 17 Nov 2025 15:43:47 +0100 Subject: [PATCH 044/230] chore: add tqdm to dev dependencies and disable reruns in pytest --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index ae09cca..de5c905 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -55,4 +55,5 @@ dev = [ "pytest-rerunfailures>=16.1", "pytest-xdist[psutil]>=3.8.0", "ruff>=0.12.2", + "tqdm>=4.67.0", ] From 4a9ad6c0fada19bfdc09e5050fdc484f276bc24c Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Mon, 17 Nov 2025 16:59:40 +0100 Subject: [PATCH 045/230] test(lua.core): simplify test_validator using api helper --- tests/lua/core/test_validator.py | 60 +++++++++++--------------------- 1 file changed, 20 insertions(+), 40 deletions(-) diff --git a/tests/lua/core/test_validator.py b/tests/lua/core/test_validator.py index ca80a68..25197ad 100644 --- a/tests/lua/core/test_validator.py +++ b/tests/lua/core/test_validator.py @@ -10,10 +10,9 @@ import socket from tests.lua.conftest import ( + api, assert_error_response, assert_success_response, - receive_response, - send_request, ) # ============================================================================ @@ -26,7 +25,7 @@ class TestTypeValidation: def test_valid_string_type(self, client: socket.socket) -> None: """Test that valid string type passes validation.""" - send_request( + response = api( client, "test_validation", { @@ -34,12 +33,11 @@ def test_valid_string_type(self, client: socket.socket) -> None: "string_field": "hello", }, ) - response = receive_response(client) assert_success_response(response) def test_invalid_string_type(self, client: socket.socket) -> None: """Test that invalid string type fails validation.""" - send_request( + response = api( client, "test_validation", { @@ -47,7 +45,6 @@ def test_invalid_string_type(self, client: socket.socket) -> None: "string_field": 123, # Should be string }, ) - response = receive_response(client) assert_error_response( response, expected_error_code="SCHEMA_INVALID_TYPE", @@ -56,7 +53,7 @@ def test_invalid_string_type(self, client: socket.socket) -> None: def test_valid_integer_type(self, client: socket.socket) -> None: """Test that valid integer type passes validation.""" - send_request( + response = api( client, "test_validation", { @@ -64,12 +61,11 @@ def test_valid_integer_type(self, client: socket.socket) -> None: "integer_field": 42, }, ) - response = receive_response(client) assert_success_response(response) def test_invalid_integer_type_float(self, client: socket.socket) -> None: """Test that float fails integer validation.""" - send_request( + response = api( client, "test_validation", { @@ -77,7 +73,6 @@ def test_invalid_integer_type_float(self, client: socket.socket) -> None: "integer_field": 42.5, # Should be integer }, ) - response = receive_response(client) assert_error_response( response, expected_error_code="SCHEMA_INVALID_TYPE", @@ -86,7 +81,7 @@ def test_invalid_integer_type_float(self, client: socket.socket) -> None: def test_invalid_integer_type_string(self, client: socket.socket) -> None: """Test that string fails integer validation.""" - send_request( + response = api( client, "test_validation", { @@ -94,7 +89,6 @@ def test_invalid_integer_type_string(self, client: socket.socket) -> None: "integer_field": "42", }, ) - response = receive_response(client) assert_error_response( response, expected_error_code="SCHEMA_INVALID_TYPE", @@ -103,7 +97,7 @@ def test_invalid_integer_type_string(self, client: socket.socket) -> None: def test_valid_array_type(self, client: socket.socket) -> None: """Test that valid array type passes validation.""" - send_request( + response = api( client, "test_validation", { @@ -111,12 +105,11 @@ def test_valid_array_type(self, client: socket.socket) -> None: "array_field": [1, 2, 3], }, ) - response = receive_response(client) assert_success_response(response) def test_invalid_array_type_not_sequential(self, client: socket.socket) -> None: """Test that non-sequential table fails array validation.""" - send_request( + response = api( client, "test_validation", { @@ -124,7 +117,6 @@ def test_invalid_array_type_not_sequential(self, client: socket.socket) -> None: "array_field": {"key": "value"}, # Not an array }, ) - response = receive_response(client) assert_error_response( response, expected_error_code="SCHEMA_INVALID_TYPE", @@ -133,7 +125,7 @@ def test_invalid_array_type_not_sequential(self, client: socket.socket) -> None: def test_invalid_array_type_string(self, client: socket.socket) -> None: """Test that string fails array validation.""" - send_request( + response = api( client, "test_validation", { @@ -141,7 +133,6 @@ def test_invalid_array_type_string(self, client: socket.socket) -> None: "array_field": "not an array", }, ) - response = receive_response(client) assert_error_response( response, expected_error_code="SCHEMA_INVALID_TYPE", @@ -159,22 +150,20 @@ class TestRequiredFields: def test_required_field_present(self, client: socket.socket) -> None: """Test that request with required field passes.""" - send_request( + response = api( client, "test_validation", {"required_field": "present"}, ) - response = receive_response(client) assert_success_response(response) def test_required_field_missing(self, client: socket.socket) -> None: """Test that request without required field fails.""" - send_request( + response = api( client, "test_validation", {}, # Missing required_field ) - response = receive_response(client) assert_error_response( response, expected_error_code="SCHEMA_MISSING_REQUIRED", @@ -183,7 +172,7 @@ def test_required_field_missing(self, client: socket.socket) -> None: def test_optional_field_missing(self, client: socket.socket) -> None: """Test that missing optional fields are allowed.""" - send_request( + response = api( client, "test_validation", { @@ -191,7 +180,6 @@ def test_optional_field_missing(self, client: socket.socket) -> None: # All other fields are optional }, ) - response = receive_response(client) assert_success_response(response) @@ -205,7 +193,7 @@ class TestArrayItemTypes: def test_array_of_integers_valid(self, client: socket.socket) -> None: """Test that array of integers passes.""" - send_request( + response = api( client, "test_validation", { @@ -213,12 +201,11 @@ def test_array_of_integers_valid(self, client: socket.socket) -> None: "array_of_integers": [1, 2, 3], }, ) - response = receive_response(client) assert_success_response(response) def test_array_of_integers_invalid_float(self, client: socket.socket) -> None: """Test that array with float items fails integer validation.""" - send_request( + response = api( client, "test_validation", { @@ -226,7 +213,6 @@ def test_array_of_integers_invalid_float(self, client: socket.socket) -> None: "array_of_integers": [1, 2.5, 3], }, ) - response = receive_response(client) assert_error_response( response, expected_error_code="SCHEMA_INVALID_ARRAY_ITEMS", @@ -235,7 +221,7 @@ def test_array_of_integers_invalid_float(self, client: socket.socket) -> None: def test_array_of_integers_invalid_string(self, client: socket.socket) -> None: """Test that array with string items fails integer validation.""" - send_request( + response = api( client, "test_validation", { @@ -243,7 +229,6 @@ def test_array_of_integers_invalid_string(self, client: socket.socket) -> None: "array_of_integers": [1, "2", 3], }, ) - response = receive_response(client) assert_error_response( response, expected_error_code="SCHEMA_INVALID_ARRAY_ITEMS", @@ -261,7 +246,7 @@ class TestFailFastBehavior: def test_multiple_errors_returns_first(self, client: socket.socket) -> None: """Test that only the first error is returned when multiple errors exist.""" - send_request( + response = api( client, "test_validation", { @@ -270,7 +255,6 @@ def test_multiple_errors_returns_first(self, client: socket.socket) -> None: "integer_field": "not an integer", # Type error (another error) }, ) - response = receive_response(client) # Should get ONE error (fail-fast), not all errors # The specific error depends on Lua table iteration order assert_error_response(response) @@ -293,17 +277,16 @@ def test_empty_arguments_with_only_required_field( self, client: socket.socket ) -> None: """Test that arguments with only required field passes.""" - send_request( + response = api( client, "test_validation", {"required_field": "only this"}, ) - response = receive_response(client) assert_success_response(response) def test_all_fields_provided(self, client: socket.socket) -> None: """Test request with multiple valid fields.""" - send_request( + response = api( client, "test_validation", { @@ -314,12 +297,11 @@ def test_all_fields_provided(self, client: socket.socket) -> None: "array_of_integers": [4, 5, 6], }, ) - response = receive_response(client) assert_success_response(response) def test_empty_array_when_allowed(self, client: socket.socket) -> None: """Test that empty array passes when no min constraint.""" - send_request( + response = api( client, "test_validation", { @@ -327,17 +309,15 @@ def test_empty_array_when_allowed(self, client: socket.socket) -> None: "array_field": [], }, ) - response = receive_response(client) assert_success_response(response) def test_empty_string_when_allowed(self, client: socket.socket) -> None: """Test that empty string passes when no min constraint.""" - send_request( + response = api( client, "test_validation", { "required_field": "", # Empty but present }, ) - response = receive_response(client) assert_success_response(response) From 9b9283c6a0cd164d3ed9ce70e7c96352facb6fe2 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Mon, 17 Nov 2025 18:00:22 +0100 Subject: [PATCH 046/230] feat(lua.utils): add types for gamestate and endpoint --- src/lua/utils/types.lua | 146 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 146 insertions(+) create mode 100644 src/lua/utils/types.lua diff --git a/src/lua/utils/types.lua b/src/lua/utils/types.lua new file mode 100644 index 0000000..0ff08e5 --- /dev/null +++ b/src/lua/utils/types.lua @@ -0,0 +1,146 @@ +---@meta + +-- ========================================================================== +-- Endpoint Type +-- ========================================================================== + +---@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 string[]? Optional list of required game states +---@field execute fun(args: table, send_response: fun(response: table)) Execute function + +-- ========================================================================== +-- GameState Types +-- ========================================================================== + +---@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? Blind information (keys: "small", "big", "boss") +---@field jokers Area? Jokers area +---@field consumables Area? Consumables area +---@field hand Area? Hand area (available during playing phase) +---@field shop Area? Shop area (available during shop phase) +---@field vouchers Area? Vouchers area (available during shop phase) + +---@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 +---| "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 +---| "SMODS_BOOSTER_OPENED" # 999 +---| "UNKNOWN" + +---@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 name string Name of the blind (e.g., "Small", "Big", "The Wall") +---@field score integer Score requirement to beat this blind +---@field status "pending" | "current" | "completed" Status of the blind +---@field effect string Description of the blind's effect +---@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 set "default" | "joker" | "tarot" | "planet" | "spectral" | "enhanced" 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 "H" | "D" | "C" | "S"? Suit (Hearts, Diamonds, Clubs, Spades) - only for playing cards +---@field value "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" | "T" | "J" | "Q" | "K" | "A"? Rank - only for playing cards +---@field effect string Description of the card's effect (from UI) + +---@class Card.Modifier +---@field seal "red" | "blue" | "gold" | "purple"? Seal type +---@field edition "holo" | "foil" | "polychrome" | "negative"? Edition type +---@field enhancement "bonus" | "mult" | "wild" | "glass" | "steel" | "stone" | "gold" | "lucky"? Enhancement type +---@field eternal boolean? If true, card cannot be sold or destroyed +---@field perishable integer? Number of rounds remaining (only if > 0) +---@field rental boolean? If true, card costs money at end of round + +---@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) From c13f56e6716c12028f0c0d2d67b2db548bab57f5 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Mon, 17 Nov 2025 18:00:48 +0100 Subject: [PATCH 047/230] feat(lua.utils): add gamestate.lua to utils --- src/lua/utils/gamestate.lua | 614 ++++++++++++++++++++++++++++++++++++ 1 file changed, 614 insertions(+) create mode 100644 src/lua/utils/gamestate.lua diff --git a/src/lua/utils/gamestate.lua b/src/lua/utils/gamestate.lua new file mode 100644 index 0000000..70d2285 --- /dev/null +++ b/src/lua/utils/gamestate.lua @@ -0,0 +1,614 @@ +---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 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 = card.seal + end + + -- Edition (table with type/key) + if card.edition and card.edition.type then + modifier.edition = 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 = card.ability.effect + 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 = card.config.card.suit + end + if card.config.card.value then + value.value = 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 card.ability.effect and card.ability.effect ~= "Base" then + set = "enhanced" + end + end + + return { + id = card.sort_id or 0, + 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 (adapted from old utils) +-- ========================================================================== + +---Gets comprehensive blind information for the current ante +---@return table blinds Information about small, big, and boss blinds +local function get_blinds_info() + local blinds = { + small = { + name = "Small", + score = 0, + status = "pending", + effect = "", + tag_name = "", + tag_effect = "", + }, + big = { + name = "Big", + score = 0, + status = "pending", + effect = "", + tag_name = "", + tag_effect = "", + }, + boss = { + name = "", + score = 0, + status = "pending", + 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) + if G.GAME.round_resets.blind_states and G.GAME.round_resets.blind_states.Small then + local status = G.GAME.round_resets.blind_states.Small + if status == "Defeated" or status == "Skipped" then + blinds.small.status = "completed" + elseif status == "Current" or status == "Select" then + blinds.small.status = "current" + end + end + + -- Big blind (1.5x multiplier) + blinds.big.score = math.floor(base_amount * 1.5 * ante_scaling) + if G.GAME.round_resets.blind_states and G.GAME.round_resets.blind_states.Big then + local status = G.GAME.round_resets.blind_states.Big + if status == "Defeated" or status == "Skipped" then + blinds.big.status = "completed" + elseif status == "Current" or status == "Select" then + blinds.big.status = "current" + end + end + + -- Boss blind + local boss_choice = G.GAME.round_resets.blind_choices and G.GAME.round_resets.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 "" + blinds.boss.score = math.floor(base_amount * (boss_blind.mult or 2) * ante_scaling) + + if G.GAME.round_resets.blind_states and G.GAME.round_resets.blind_states.Boss then + local status = G.GAME.round_resets.blind_states.Boss + if status == "Defeated" or status == "Skipped" then + blinds.boss.status = "completed" + elseif status == "Current" or status == "Select" then + blinds.boss.status = "current" + end + end + + -- Get boss effect description + if boss_blind.key and localize then ---@diagnostic disable-line: undefined-global + 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) + if G.GAME.round_resets.blind_states and G.GAME.round_resets.blind_states.Boss then + local status = G.GAME.round_resets.blind_states.Boss + if status == "Defeated" or status == "Skipped" then + blinds.boss.status = "completed" + elseif status == "Current" or status == "Select" then + blinds.boss.status = "current" + end + end + end + + -- Get tag information for Small and Big blinds + if G.GAME.round_resets.blind_tags and G.P_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 + if localize then ---@diagnostic disable-line: undefined-global + 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 + 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 + if localize then ---@diagnostic disable-line: undefined-global + 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 = tag_effect[2] .. " " .. tag_effect[2] + end + end + end + end + 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 + + -- 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 = 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 + + return state_data +end + +return gamestate From c52b2e639a1a5d3ea5dbcf32746fafd0770b7c61 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Mon, 17 Nov 2025 18:01:09 +0100 Subject: [PATCH 048/230] feat(lua.utils): add SCHEMA_INVALID_VALUE error --- src/lua/utils/errors.lua | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/lua/utils/errors.lua b/src/lua/utils/errors.lua index 83b4982..647e1cf 100644 --- a/src/lua/utils/errors.lua +++ b/src/lua/utils/errors.lua @@ -18,6 +18,7 @@ ---@field SCHEMA_INVALID_TYPE string ---@field SCHEMA_MISSING_REQUIRED string ---@field SCHEMA_INVALID_ARRAY_ITEMS string +---@field SCHEMA_INVALID_VALUE string ---@field STATE_INVALID_STATE string ---@field STATE_NOT_READY string ---@field GAME_NOT_IN_RUN string @@ -41,6 +42,7 @@ return { SCHEMA_INVALID_TYPE = "SCHEMA_INVALID_TYPE", -- Argument type mismatch SCHEMA_MISSING_REQUIRED = "SCHEMA_MISSING_REQUIRED", -- Required argument missing SCHEMA_INVALID_ARRAY_ITEMS = "SCHEMA_INVALID_ARRAY_ITEMS", -- Invalid array item type + SCHEMA_INVALID_VALUE = "SCHEMA_INVALID_VALUE", -- Argument value out of range or invalid -- STATE_* : Game state validation errors (wrong state for action) STATE_INVALID_STATE = "STATE_INVALID_STATE", -- Action not allowed in current state From fee0af6c7f495881b75a62dc14eecd93b00c28fc Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Mon, 17 Nov 2025 18:01:45 +0100 Subject: [PATCH 049/230] chore(lua.endpoints): rm endpoint type --- src/lua/endpoints/health.lua | 7 ------- 1 file changed, 7 deletions(-) diff --git a/src/lua/endpoints/health.lua b/src/lua/endpoints/health.lua index b936b86..17a776a 100644 --- a/src/lua/endpoints/health.lua +++ b/src/lua/endpoints/health.lua @@ -4,13 +4,6 @@ -- Simple synchronous endpoint for connection testing and readiness checks -- Returns server status and basic game information immediately ----@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 string[]? Optional list of required game states ----@field execute fun(args: table, send_response: fun(response: table)) Execute function - ---@type Endpoint return { name = "health", From 4ceb27c1dc0a8ce06d7b087ff25b47147fb50929 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Mon, 17 Nov 2025 18:02:33 +0100 Subject: [PATCH 050/230] feat(lua.endpoints): add gamestate endpoint --- src/lua/endpoints/gamestate.lua | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 src/lua/endpoints/gamestate.lua diff --git a/src/lua/endpoints/gamestate.lua b/src/lua/endpoints/gamestate.lua new file mode 100644 index 0000000..0835b78 --- /dev/null +++ b/src/lua/endpoints/gamestate.lua @@ -0,0 +1,28 @@ +-- src/lua/endpoints/gamestate.lua +-- Gamestate Endpoint +-- +-- Returns the current game state extracted via the gamestate utility +-- Provides a simplified view of the game optimized for bot decision-making + +local gamestate = assert(SMODS.load_file("src/lua/utils/gamestate.lua"))() + +---@type Endpoint +return { + name = "gamestate", + + description = "Get current game state", + + schema = {}, -- No arguments required + + requires_state = nil, -- Can be called from any state + + ---@param _ table The arguments (empty for gamestate) + ---@param send_response fun(response: table) Callback to send response + execute = function(_, send_response) + -- Get current game state + local state_data = gamestate.get_gamestate() + + -- Return the game state + send_response(state_data) + end, +} From c3e3d0d1ba9d9f731704b8064875e4e30fad3203 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Mon, 17 Nov 2025 18:02:49 +0100 Subject: [PATCH 051/230] feat(lua.endpoints): add load endpoint --- src/lua/endpoints/load.lua | 89 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 src/lua/endpoints/load.lua diff --git a/src/lua/endpoints/load.lua b/src/lua/endpoints/load.lua new file mode 100644 index 0000000..1355b92 --- /dev/null +++ b/src/lua/endpoints/load.lua @@ -0,0 +1,89 @@ +-- src/lua/endpoints/load.lua +-- Load Game State Endpoint +-- +-- Loads a saved game run state from a file using nativefs + +local nativefs = require("nativefs") +local errors = assert(SMODS.load_file("src/lua/utils/errors.lua"))() + +---@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 table The arguments with 'path' field + ---@param send_response fun(response: table) Callback to send response + 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({ + error = "File not found: '" .. path .. "'", + error_code = errors.EXEC_FILE_NOT_FOUND, + }) + return + end + + -- Read file using nativefs + local compressed_data = nativefs.read(path) + ---@cast compressed_data string + if not compressed_data then + send_response({ + error = "Failed to read save file", + error_code = errors.EXEC_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({ + error = "Failed to prepare save file for loading", + error_code = errors.EXEC_INTERNAL_ERROR, + }) + return + end + + -- Load using game's built-in functions + G:delete_run() + G.SAVED_GAME = get_compressed(temp_filename) + + if G.SAVED_GAME == nil then + send_response({ + error = "Invalid save file format", + error_code = errors.EXEC_INVALID_SAVE_FORMAT, + }) + love.filesystem.remove(temp_filename) + return + end + + G.SAVED_GAME = STR_UNPACK(G.SAVED_GAME) + G:start_run({ savetext = G.SAVED_GAME }) + + -- Clean up + love.filesystem.remove(temp_filename) + + send_response({ + success = true, + path = path, + }) + end, +} From 8dba7211b92095215825649ed1045c3abc9bd152 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Mon, 17 Nov 2025 18:03:04 +0100 Subject: [PATCH 052/230] feat(lua.endpoints): add menu endpoint --- src/lua/endpoints/menu.lua | 42 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 src/lua/endpoints/menu.lua diff --git a/src/lua/endpoints/menu.lua b/src/lua/endpoints/menu.lua new file mode 100644 index 0000000..eb93901 --- /dev/null +++ b/src/lua/endpoints/menu.lua @@ -0,0 +1,42 @@ +-- src/lua/endpoints/menu.lua +-- Menu Endpoint +-- +-- Returns to the main menu from any game state + +local gamestate = assert(SMODS.load_file("src/lua/utils/gamestate.lua"))() + +---@type Endpoint +return { + name = "menu", + + description = "Return to the main menu from any game state", + + schema = {}, + + requires_state = nil, + + ---@param _ table The arguments (empty for menu) + ---@param send_response fun(response: table) Callback to send response + execute = function(_, send_response) + sendDebugMessage("Init menu()", "BB.ENDPOINTS") + G.FUNCS.go_to_menu({}) + + -- 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 = gamestate.get_gamestate() + send_response(state_data) + end + + return done + end, + })) + end, +} From 1b06580fdf2d2f0057e384b2eab31db1ac22c531 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Mon, 17 Nov 2025 18:03:13 +0100 Subject: [PATCH 053/230] feat(lua.endpoints): add save endpoint --- src/lua/endpoints/save.lua | 93 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 src/lua/endpoints/save.lua diff --git a/src/lua/endpoints/save.lua b/src/lua/endpoints/save.lua new file mode 100644 index 0000000..294a19e --- /dev/null +++ b/src/lua/endpoints/save.lua @@ -0,0 +1,93 @@ +-- src/lua/endpoints/save.lua +-- Save Game State Endpoint +-- +-- Saves the current game run state to a file using nativefs + +local nativefs = require("nativefs") +local errors = assert(SMODS.load_file("src/lua/utils/errors.lua"))() + +---@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", + }, + }, + + -- All states that occur during an active run (G.STAGES.RUN) + -- Excludes: MENU, SPLASH, SANDBOX, TUTORIAL, DEMO_CTA + requires_state = { + G.STATES.SELECTING_HAND, -- 1 + G.STATES.HAND_PLAYED, -- 2 + G.STATES.DRAW_TO_HAND, -- 3 + G.STATES.GAME_OVER, -- 4 + G.STATES.SHOP, -- 5 + G.STATES.PLAY_TAROT, -- 6 + G.STATES.BLIND_SELECT, -- 7 + G.STATES.ROUND_EVAL, -- 8 + G.STATES.TAROT_PACK, -- 9 + G.STATES.PLANET_PACK, -- 10 + G.STATES.SPECTRAL_PACK, -- 15 + G.STATES.STANDARD_PACK, -- 17 + G.STATES.BUFFOON_PACK, -- 18 + G.STATES.NEW_ROUND, -- 19 + }, + + ---@param args table The arguments with 'path' field + ---@param send_response fun(response: table) Callback to send response + 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({ + error = "Can only save during an active run", + error_code = errors.GAME_NOT_IN_RUN, + }) + 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({ + error = "Failed to save game state", + error_code = errors.EXEC_INTERNAL_ERROR, + }) + return + end + + local write_success = nativefs.write(path, compressed_data) + if not write_success then + send_response({ + error = "Failed to write save file to '" .. path .. "'", + error_code = errors.EXEC_INTERNAL_ERROR, + }) + return + end + + -- Clean up + love.filesystem.remove(temp_filename) + + send_response({ + success = true, + path = path, + }) + end, +} From f8fdd9710a9a10371393621667224ff441393908 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Mon, 17 Nov 2025 18:03:23 +0100 Subject: [PATCH 054/230] feat(lua.endpoints): add start endpoint --- src/lua/endpoints/start.lua | 157 ++++++++++++++++++++++++++++++++++++ 1 file changed, 157 insertions(+) create mode 100644 src/lua/endpoints/start.lua diff --git a/src/lua/endpoints/start.lua b/src/lua/endpoints/start.lua new file mode 100644 index 0000000..f407a84 --- /dev/null +++ b/src/lua/endpoints/start.lua @@ -0,0 +1,157 @@ +-- src/lua/endpoints/start.lua +-- Start Endpoint +-- +-- Starts a new game run with specified deck and stake + +local gamestate = assert(SMODS.load_file("src/lua/utils/gamestate.lua"))() +local errors = assert(SMODS.load_file("src/lua/utils/errors.lua"))() + +-- Mapping tables for enum values +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, +} + +---@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 table The arguments (deck, stake, seed?) + ---@param send_response fun(response: table) Callback to send response + 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({ + error = "Invalid stake enum. Must be one of: WHITE, RED, GREEN, BLACK, BLUE, PURPLE, ORANGE, GOLD. Got: " + .. tostring(args.stake), + error_code = errors.SCHEMA_INVALID_VALUE, + }) + 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({ + error = "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), + error_code = errors.SCHEMA_INVALID_VALUE, + }) + 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({ + error = "Deck not found in game data: " .. deck_name, + error_code = errors.EXEC_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 = true, + func = function() + -- Run is ready when we're in BLIND_SELECT state with a blind on deck + local done = G.STATE == G.STATES.BLIND_SELECT + + if done then + sendDebugMessage("Return start()", "BB.ENDPOINTS") + local state_data = gamestate.get_gamestate() + send_response(state_data) + end + + return done + end, + })) + end, +} From eb37ae815ad5f823565ecb42a9499dd3d0de4de6 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Mon, 17 Nov 2025 18:04:48 +0100 Subject: [PATCH 055/230] test(lua.endpoints): add test for gamestate endpoint --- tests/lua/endpoints/test_gamestate.py | 51 +++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 tests/lua/endpoints/test_gamestate.py diff --git a/tests/lua/endpoints/test_gamestate.py b/tests/lua/endpoints/test_gamestate.py new file mode 100644 index 0000000..a8e7dfa --- /dev/null +++ b/tests/lua/endpoints/test_gamestate.py @@ -0,0 +1,51 @@ +"""Tests for src/lua/endpoints/gamestate.lua""" + +import socket +from typing import Any + +from tests.lua.conftest import api, get_fixture_path + + +def verify_base_gamestate_response(response: dict[str, Any]) -> None: + """Verify that gamestate response has all base fields.""" + # Verify state field + assert "state" in response + assert isinstance(response["state"], str) + assert len(response["state"]) > 0 + + # Verify round_num field + assert "round_num" in response + assert isinstance(response["round_num"], int) + assert response["round_num"] >= 0 + + # Verify ante_num field + assert "ante_num" in response + assert isinstance(response["ante_num"], int) + assert response["ante_num"] >= 0 + + # Verify money field + assert "money" in response + assert isinstance(response["money"], int) + assert response["money"] >= 0 + + +class TestGamestateEndpoint: + """Test basic gamestate endpoint and gamestate response structure.""" + + def test_gamestate_from_MENU(self, client: socket.socket) -> None: + """Test that gamestate endpoint from MENU state is valid.""" + api(client, "menu", {}) + response = api(client, "gamestate", {}) + verify_base_gamestate_response(response) + assert response["state"] == "MENU" + + def test_gamestate_from_BLIND_SELECT(self, client: socket.socket) -> None: + """Test that gamestate from BLIND_SELECT state is valid.""" + save = "state-BLIND_SELECT--round_num-0--deck-RED--stake-WHITE.jkr" + api(client, "load", {"path": str(get_fixture_path("gamestate", save))}) + response = api(client, "gamestate", {}) + verify_base_gamestate_response(response) + assert response["state"] == "BLIND_SELECT" + assert response["round_num"] == 0 + assert response["deck"] == "RED" + assert response["stake"] == "WHITE" From db70dfcda44701fc225722dab44a5cbda048049a Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Mon, 17 Nov 2025 18:05:00 +0100 Subject: [PATCH 056/230] test(lua.endpoints): update tests for health endpoint --- tests/lua/endpoints/test_health.py | 170 +++-------------------------- 1 file changed, 15 insertions(+), 155 deletions(-) diff --git a/tests/lua/endpoints/test_health.py b/tests/lua/endpoints/test_health.py index b3d721d..917cc43 100644 --- a/tests/lua/endpoints/test_health.py +++ b/tests/lua/endpoints/test_health.py @@ -6,169 +6,29 @@ # - Response structure and fields import socket +from typing import Any -import pytest +from tests.lua.conftest import api, get_fixture_path -from tests.lua.conftest import assert_health_response, receive_response, send_request -# ============================================================================ -# Test: Health Endpoint Basics -# ============================================================================ +def assert_health_response(response: dict[str, Any]) -> None: + assert "status" in response + assert response["status"] == "ok" -class TestHealthEndpointBasics: +class TestHealthEndpoint: """Test basic health endpoint functionality.""" - def test_health_check_succeeds(self, client: socket.socket) -> None: + def test_health_from_MENU(self, client: socket.socket) -> None: """Test that health check returns status ok.""" - send_request(client, "health", {}) - response = receive_response(client) + response = api(client, "menu", {}) + assert response["state"] == "MENU" + response = api(client, "health", {}) assert_health_response(response) - def test_health_check_with_empty_arguments(self, client: socket.socket) -> None: - """Test that health check works with empty arguments.""" - send_request(client, "health", {}) - response = receive_response(client) - assert_health_response(response) - - def test_health_check_ignores_extra_arguments(self, client: socket.socket) -> None: - """Test that health check ignores extra arguments.""" - send_request( - client, - "health", - { - "extra_field": "ignored", - "another_field": 123, - }, - ) - response = receive_response(client) - assert_health_response(response) - - -# ============================================================================ -# Test: Health Response Structure -# ============================================================================ - - -class TestHealthResponseStructure: - """Test health endpoint response structure.""" - - def test_response_has_status_field(self, client: socket.socket) -> None: - """Test that response contains status field.""" - send_request(client, "health", {}) - response = receive_response(client) - assert "status" in response - - def test_status_field_is_ok(self, client: socket.socket) -> None: - """Test that status field is 'ok'.""" - send_request(client, "health", {}) - response = receive_response(client) - assert response["status"] == "ok" - - def test_response_only_has_status_field(self, client: socket.socket) -> None: - """Test that response only contains the status field.""" - send_request(client, "health", {}) - response = receive_response(client) - assert list(response.keys()) == ["status"] - - -# ============================================================================ -# Test: Multiple Health Checks -# ============================================================================ - - -class TestMultipleHealthChecks: - """Test multiple sequential health checks.""" - - @pytest.mark.parametrize("iteration", range(10)) - def test_multiple_health_checks_succeed( - self, client: socket.socket, iteration: int - ) -> None: - """Test that multiple health checks all succeed.""" - send_request(client, "health", {}) - response = receive_response(client) - assert_health_response(response) - - def test_health_check_responses_consistent(self, client: socket.socket) -> None: - """Test that health check responses are consistent.""" - send_request(client, "health", {}) - response1 = receive_response(client) - - send_request(client, "health", {}) - response2 = receive_response(client) - - # Responses should be identical - assert response1 == response2 - assert response1["status"] == "ok" - assert response2["status"] == "ok" - - -# ============================================================================ -# Test: Health Check Edge Cases -# ============================================================================ - - -class TestHealthCheckEdgeCases: - """Test edge cases for health endpoint.""" - - def test_health_check_fast_response(self, client: socket.socket) -> None: - """Test that health check responds quickly (synchronous).""" - from time import time - - start = time() - send_request(client, "health", {}) - response = receive_response(client, timeout=1.0) - elapsed = time() - start - - # Should respond in less than 1 second (it's synchronous) - assert elapsed < 1.0 - assert_health_response(response) - - @pytest.mark.parametrize("iteration", range(5)) - def test_health_check_no_side_effects( - self, - client: socket.socket, - iteration: int, - ) -> None: - """Test that health check has no side effects.""" - send_request(client, "health", {}) - response = receive_response(client) + def test_health_from_BLIND_SELECT(self, client: socket.socket) -> None: + """Test that health check returns status ok.""" + save = "state-BLIND_SELECT.jkr" + api(client, "load", {"path": str(get_fixture_path("health", save))}) + response = api(client, "health", {}) assert_health_response(response) - - -# ============================================================================ -# Test: Health Check Integration -# ============================================================================ - - -class TestHealthCheckIntegration: - """Test health check integration with other endpoints.""" - - def test_health_check_after_validation_endpoint( - self, client: socket.socket - ) -> None: - """Test health check after using validation endpoint.""" - # Use validation endpoint - send_request(client, "test_validation", {"required_field": "test"}) - validation_response = receive_response(client) - assert validation_response["success"] is True - - # Then health check - send_request(client, "health", {}) - health_response = receive_response(client) - assert_health_response(health_response) - - @pytest.mark.parametrize("iteration", range(5)) - def test_alternating_health_and_validation( - self, client: socket.socket, iteration: int - ) -> None: - """Test alternating between health and validation requests.""" - # Health check - send_request(client, "health", {}) - health_response = receive_response(client) - assert_health_response(health_response) - - # Validation endpoint - send_request(client, "test_validation", {"required_field": "test"}) - validation_response = receive_response(client) - assert validation_response["success"] is True From 6f21b946abb5708ce3d79ad060993126e14774b3 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Mon, 17 Nov 2025 18:05:22 +0100 Subject: [PATCH 057/230] test(lua.endpoints): add test for load endpoint --- tests/lua/endpoints/test_load.py | 65 ++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 tests/lua/endpoints/test_load.py diff --git a/tests/lua/endpoints/test_load.py b/tests/lua/endpoints/test_load.py new file mode 100644 index 0000000..9c902df --- /dev/null +++ b/tests/lua/endpoints/test_load.py @@ -0,0 +1,65 @@ +"""Tests for src/lua/endpoints/load.lua""" + +import socket +from pathlib import Path + +from tests.lua.conftest import ( + api, + assert_error_response, + assert_success_response, + get_fixture_path, +) + + +class TestLoadEndpoint: + """Test basic load endpoint functionality.""" + + def test_load_from_fixture(self, client: socket.socket) -> None: + """Test that load succeeds with a valid fixture file.""" + fixture_path = get_fixture_path("load", "state-BLIND_SELECT.jkr") + + response = api(client, "load", {"path": str(fixture_path)}) + + assert_success_response(response) + assert response["path"] == str(fixture_path) + + def test_load_save_roundtrip(self, client: socket.socket, tmp_path: Path) -> None: + """Test that a loaded fixture can be saved and loaded again.""" + # Load fixture + fixture_path = get_fixture_path("load", "state-BLIND_SELECT.jkr") + load_response = api(client, "load", {"path": str(fixture_path)}) + assert_success_response(load_response) + + # Save to temp path + temp_file = tmp_path / "save.jkr" + save_response = api(client, "save", {"path": str(temp_file)}) + assert_success_response(save_response) + assert temp_file.exists() + + # Load the saved file back + load_again_response = api(client, "load", {"path": str(temp_file)}) + assert_success_response(load_again_response) + + +class TestLoadValidation: + """Test load endpoint parameter validation.""" + + def test_missing_path_parameter(self, client: socket.socket) -> None: + """Test that load fails when path parameter is missing.""" + response = api(client, "load", {}) + + assert_error_response( + response, + expected_error_code="SCHEMA_MISSING_REQUIRED", + expected_message_contains="Missing required field 'path'", + ) + + def test_invalid_path_type(self, client: socket.socket) -> None: + """Test that load fails when path is not a string.""" + response = api(client, "load", {"path": 123}) + + assert_error_response( + response, + expected_error_code="SCHEMA_INVALID_TYPE", + expected_message_contains="Field 'path' must be of type string", + ) From 57481b41eb4a06edcae5fd67613c71a953fe0ce0 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Mon, 17 Nov 2025 18:05:37 +0100 Subject: [PATCH 058/230] test(lua.endpoints): add test for menu endpoint --- tests/lua/endpoints/test_menu.py | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 tests/lua/endpoints/test_menu.py diff --git a/tests/lua/endpoints/test_menu.py b/tests/lua/endpoints/test_menu.py new file mode 100644 index 0000000..4b8e936 --- /dev/null +++ b/tests/lua/endpoints/test_menu.py @@ -0,0 +1,31 @@ +"""Tests for src/lua/endpoints/menu.lua""" + +import socket +from typing import Any + +from tests.lua.conftest import api, get_fixture_path + + +def verify_base_menu_response(response: dict[str, Any]) -> None: + """Verify that menu response has all base fields.""" + # Verify state field + assert "state" in response + assert isinstance(response["state"], str) + assert len(response["state"]) > 0 + + +class TestMenuEndpoint: + """Test basic menu endpoint and menu response structure.n""" + + def test_menu_from_MENU(self, client: socket.socket) -> None: + """Test that menu endpoint returns state as MENU.""" + api(client, "menu", {}) + response = api(client, "menu", {}) + verify_base_menu_response(response) + + def test_menu_from_BLIND_SELECT(self, client: socket.socket) -> None: + """Test that menu endpoint returns state as MENU.""" + save = "state-BLIND_SELECT--round_num-0--deck-RED--stake-WHITE.jkr" + api(client, "load", {"path": str(get_fixture_path("menu", save))}) + response = api(client, "menu", {}) + verify_base_menu_response(response) From 743e6c81760fe012229fff0c3b74f8e3dae40add Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Mon, 17 Nov 2025 18:06:01 +0100 Subject: [PATCH 059/230] test(lua.endpoints): add save endpoint tests --- tests/lua/endpoints/test_save.py | 78 ++++++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 tests/lua/endpoints/test_save.py diff --git a/tests/lua/endpoints/test_save.py b/tests/lua/endpoints/test_save.py new file mode 100644 index 0000000..fea36c8 --- /dev/null +++ b/tests/lua/endpoints/test_save.py @@ -0,0 +1,78 @@ +"""Tests for src/lua/endpoints/save.lua""" + +import socket +from pathlib import Path + +from tests.lua.conftest import ( + api, + assert_error_response, + assert_success_response, + get_fixture_path, +) + + +class TestSaveEndpoint: + """Test basic save endpoint functionality.""" + + def test_save_from_BLIND_SELECT( + self, client: socket.socket, tmp_path: Path + ) -> None: + """Test that save succeeds from BLIND_SELECT state.""" + save = "state-BLIND_SELECT.jkr" + api(client, "load", {"path": str(get_fixture_path("save", save))}) + temp_file = tmp_path / "save.jkr" + response = api(client, "save", {"path": str(temp_file)}) + assert_success_response(response) + assert response["path"] == str(temp_file) + assert temp_file.exists() + assert temp_file.stat().st_size > 0 + + def test_save_creates_valid_file( + self, client: socket.socket, tmp_path: Path + ) -> None: + """Test that saved file can be loaded back successfully.""" + save = "state-BLIND_SELECT.jkr" + api(client, "load", {"path": str(get_fixture_path("save", save))}) + temp_file = tmp_path / "save.jkr" + save_response = api(client, "save", {"path": str(temp_file)}) + assert_success_response(save_response) + load_response = api(client, "load", {"path": str(temp_file)}) + assert_success_response(load_response) + + +class TestSaveValidation: + """Test save endpoint parameter validation.""" + + def test_missing_path_parameter(self, client: socket.socket) -> None: + """Test that save fails when path parameter is missing.""" + response = api(client, "save", {}) + assert_error_response( + response, + expected_error_code="SCHEMA_MISSING_REQUIRED", + expected_message_contains="Missing required field 'path'", + ) + + def test_invalid_path_type(self, client: socket.socket) -> None: + """Test that save fails when path is not a string.""" + response = api(client, "save", {"path": 123}) + assert_error_response( + response, + expected_error_code="SCHEMA_INVALID_TYPE", + expected_message_contains="Field 'path' must be of type string", + ) + + +class TestSaveStateRequirements: + """Test save endpoint state requirements.""" + + def test_save_from_MENU(self, client: socket.socket, tmp_path: Path) -> None: + """Test that save fails when not in an active run.""" + api(client, "menu", {}) + temp_file = tmp_path / "save.jkr" + response = api(client, "save", {"path": str(temp_file)}) + assert_error_response( + response, + expected_error_code="STATE_INVALID_STATE", + expected_message_contains="requires one of these states", + ) + assert not temp_file.exists() From 2c7387e8b380e082e02abf5d4fd8b20a8dcc7ab7 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Mon, 17 Nov 2025 18:06:14 +0100 Subject: [PATCH 060/230] test(lua.endpoints): add tests for start endpoint --- tests/lua/endpoints/test_start.py | 161 ++++++++++++++++++++++++++++++ 1 file changed, 161 insertions(+) create mode 100644 tests/lua/endpoints/test_start.py diff --git a/tests/lua/endpoints/test_start.py b/tests/lua/endpoints/test_start.py new file mode 100644 index 0000000..3aa180f --- /dev/null +++ b/tests/lua/endpoints/test_start.py @@ -0,0 +1,161 @@ +"""Tests for the start endpoint.""" + +import socket +from typing import Any + +import pytest + +from tests.lua.conftest import api, assert_error_response, get_fixture_path + + +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: socket.socket, + arguments: dict[str, Any], + expected: dict[str, Any], + ): + """Test start endpoint with various valid parameters.""" + response = api(client, "menu", {}) + assert response["state"] == "MENU" + response = api(client, "start", arguments) + for key, value in expected.items(): + assert response[key] == value + + +class TestStartEndpointValidation: + """Test start endpoint parameter validation.""" + + @pytest.fixture(scope="class") + def client(self, host: str, port: int): + """Class-scoped client fixture for this test class.""" + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.settimeout(60) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, 65536) + sock.connect((host, port)) + + response = api(sock, "menu", {}) + assert response["state"] == "MENU" + + yield sock + + def test_missing_deck_parameter(self, client: socket.socket): + """Test that start fails when deck parameter is missing.""" + response = api(client, "start", {"stake": "WHITE"}) + assert_error_response( + response, + expected_error_code="SCHEMA_MISSING_REQUIRED", + expected_message_contains="Missing required field 'deck'", + ) + + def test_missing_stake_parameter(self, client: socket.socket): + """Test that start fails when stake parameter is missing.""" + response = api(client, "start", {"deck": "RED"}) + assert_error_response( + response, + expected_error_code="SCHEMA_MISSING_REQUIRED", + expected_message_contains="Missing required field 'stake'", + ) + + def test_invalid_deck_value(self, client: socket.socket): + """Test that start fails with invalid deck enum.""" + response = api(client, "start", {"deck": "INVALID_DECK", "stake": "WHITE"}) + assert_error_response( + response, + expected_error_code="SCHEMA_INVALID_VALUE", + expected_message_contains="Invalid deck enum. Must be one of:", + ) + + def test_invalid_stake_value(self, client: socket.socket): + """Test that start fails when invalid stake enum is provided.""" + response = api(client, "start", {"deck": "RED", "stake": "INVALID_STAKE"}) + assert_error_response( + response, + expected_error_code="SCHEMA_INVALID_VALUE", + expected_message_contains="Invalid stake enum. Must be one of:", + ) + + def test_invalid_deck_type(self, client: socket.socket): + """Test that start fails when deck is not a string.""" + response = api(client, "start", {"deck": 123, "stake": "WHITE"}) + assert_error_response( + response, + expected_error_code="SCHEMA_INVALID_TYPE", + expected_message_contains="Field 'deck' must be of type string", + ) + + def test_invalid_stake_type(self, client: socket.socket): + """Test that start fails when stake is not a string.""" + response = api(client, "start", {"deck": "RED", "stake": 1}) + assert_error_response( + response, + expected_error_code="SCHEMA_INVALID_TYPE", + expected_message_contains="Field 'stake' must be of type string", + ) + + +class TestStartEndpointStateRequirements: + """Test start endpoint state requirements.""" + + def test_start_from_BLIND_SELECT(self, client: socket.socket): + """Test that start fails when not in MENU state.""" + save = "state-BLIND_SELECT.jkr" + response = api(client, "load", {"path": str(get_fixture_path("start", save))}) + response = api(client, "start", {"deck": "RED", "stake": "WHITE"}) + assert_error_response( + response, + expected_error_code="STATE_INVALID_STATE", + expected_message_contains="Endpoint 'start' requires one of these states:", + ) From 86b44248ea091a7a4147211354fbfa9b4d353893 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Mon, 17 Nov 2025 18:06:35 +0100 Subject: [PATCH 061/230] test(lua.core): add delay to prevent server from overwhelming --- tests/lua/core/test_server.py | 29 +++++++---------------------- 1 file changed, 7 insertions(+), 22 deletions(-) diff --git a/tests/lua/core/test_server.py b/tests/lua/core/test_server.py index dd8242f..ad11c64 100644 --- a/tests/lua/core/test_server.py +++ b/tests/lua/core/test_server.py @@ -11,6 +11,7 @@ import errno import json import socket +import time import pytest @@ -63,6 +64,7 @@ def test_accepts_connections(self, client: socket.socket) -> None: def test_sequential_connections(self, port: int) -> None: """Test that server handles sequential connections correctly.""" for i in range(3): + time.sleep(0.02) # Delay to prevent overwhelming server sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.settimeout(2) try: @@ -74,6 +76,7 @@ def test_sequential_connections(self, port: int) -> None: def test_rapid_sequential_connections(self, port: int) -> None: """Test server handles rapid sequential connections.""" for i in range(5): + time.sleep(0.02) # Delay to prevent overwhelming server sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.settimeout(2) try: @@ -82,28 +85,6 @@ def test_rapid_sequential_connections(self, port: int) -> None: finally: sock.close() - def test_multiple_concurrent_connections(self, port: int) -> None: - """Test server behavior with multiple concurrent connection attempts.""" - sockets = [] - try: - for _ in range(3): - sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - sock.settimeout(1) - try: - sock.connect(("127.0.0.1", port)) - sockets.append(sock) - except (socket.timeout, ConnectionRefusedError, OSError): - sock.close() - - # At least one connection should succeed - assert len(sockets) >= 1, "At least one connection should succeed" - - for sock in sockets: - assert sock.fileno() != -1, "Connected sockets should be valid" - finally: - for sock in sockets: - sock.close() - def test_immediate_disconnect(self, port: int) -> None: """Test server handles clients that disconnect immediately.""" sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) @@ -111,6 +92,8 @@ def test_immediate_disconnect(self, port: int) -> None: sock.connect(("127.0.0.1", port)) sock.close() + time.sleep(0.1) # Delay to prevent overwhelming server + # Server should still accept new connections sock2 = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock2.settimeout(2) @@ -158,6 +141,8 @@ def test_client_disconnect_without_sending(self, port: int) -> None: sock.connect(("127.0.0.1", port)) sock.close() + time.sleep(0.1) # Delay to prevent overwhelming server + # Server should still accept new connections sock2 = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock2.settimeout(2) From 6aebc4a3e08852c87c9b933b30d09607f930579d Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Mon, 17 Nov 2025 18:07:44 +0100 Subject: [PATCH 062/230] feat: add gamestate menu and start endpoints to balatrobot loading --- balatrobot.lua | 3 +++ 1 file changed, 3 insertions(+) diff --git a/balatrobot.lua b/balatrobot.lua index cf0f655..0c2b936 100644 --- a/balatrobot.lua +++ b/balatrobot.lua @@ -9,6 +9,9 @@ BB_ENDPOINTS = { "src/lua/endpoints/health.lua", "src/lua/endpoints/save.lua", "src/lua/endpoints/load.lua", + "src/lua/endpoints/gamestate.lua", + "src/lua/endpoints/menu.lua", + "src/lua/endpoints/start.lua", -- If debug mode is enabled, debugger.lua will load test endpoints } From 389e8b600a077827b13867f686f70973b77aeb17 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Mon, 17 Nov 2025 18:08:07 +0100 Subject: [PATCH 063/230] test(fixtures): update script to generate fixtures for all endpoints --- tests/fixtures/generate.py | 290 +++++++++++++++++++++++++------------ 1 file changed, 201 insertions(+), 89 deletions(-) diff --git a/tests/fixtures/generate.py b/tests/fixtures/generate.py index f9a8a50..36a1fac 100644 --- a/tests/fixtures/generate.py +++ b/tests/fixtures/generate.py @@ -1,22 +1,24 @@ #!/usr/bin/env python3 -"""Generate test fixture files for save/load endpoint testing. +"""Generate test fixture files for endpoint testing. -This script connects to a running Balatro instance and uses the save endpoint -to generate .jkr fixture files for testing. It also creates corrupted files -for testing error handling. - -Fixtures are organized by endpoint: -- save/start.jkr - Used by save tests to get into run state -- load/start.jkr - Used by load tests to test loading -- load/corrupted.jkr - Used by load tests to test error handling +This script automatically connects to a running Balatro instance and generates +.jkr fixture files for testing endpoints. Usage: python generate.py + +Requirements: +- Balatro must be running with the BalatroBot mod loaded +- Default connection: 127.0.0.1:12346 """ import json import socket +from dataclasses import dataclass, field from pathlib import Path +from typing import Callable + +from tqdm import tqdm FIXTURES_DIR = Path(__file__).parent HOST = "127.0.0.1" @@ -24,6 +26,20 @@ BUFFER_SIZE = 65536 +@dataclass +class FixtureSpec: + """Specification for a single fixture.""" + + name: str # Display name + paths: list[Path] # Output paths (first is primary, rest are copies) + setup: Callable[[socket.socket], bool] | None = None # Game state setup + validate: bool = True # Whether to validate by loading + post_process: Callable[[Path], None] | None = ( + None # Post-processing (e.g., corruption) + ) + depends_on: list[str] = field(default_factory=list) # Dependencies + + def send_request(sock: socket.socket, name: str, arguments: dict) -> None: """Send a JSON request to the Balatro server.""" request = {"name": name, "arguments": arguments} @@ -44,101 +60,197 @@ def receive_response(sock: socket.socket, timeout: float = 3.0) -> dict: return json.loads(first_message) -def generate_start_fixtures() -> None: - """Generate start.jkr fixtures for both save and load endpoints. - - Creates identical start.jkr files in both save/ and load/ directories - from the current game state. This should be run when the game is in - an initial state (e.g., early in a run). - """ - save_fixture = FIXTURES_DIR / "save" / "start.jkr" - load_fixture = FIXTURES_DIR / "load" / "start.jkr" - - print(f"Generating start.jkr fixtures...") - - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: - sock.settimeout(10) - sock.connect((HOST, PORT)) - - # Save to save/ directory - send_request(sock, "save", {"path": str(save_fixture)}) - response = receive_response(sock) - - if "error" in response: - print( - f" Error: {response['error']} ({response.get('error_code', 'UNKNOWN')})" - ) - print(" Make sure you're in an active run before generating fixtures") - return - - if response.get("success"): - print(f" Generated {save_fixture}") - print(f" File size: {save_fixture.stat().st_size} bytes") - - # Copy to load/ directory - load_fixture.write_bytes(save_fixture.read_bytes()) - print(f" Generated {load_fixture}") - print(f" File size: {load_fixture.stat().st_size} bytes") +def start_new_game( + sock: socket.socket, + deck: str = "RED", + stake: str = "WHITE", + seed: str | None = None, +) -> bool: + """Start a new game with specified deck and stake.""" + # Ensure menu state + send_request(sock, "menu", {}) + res = receive_response(sock) + if "error" in res or res.get("state") != "MENU": + return False + + # Start game + arguments = {"deck": deck, "stake": stake} + if seed: + arguments["seed"] = seed + send_request(sock, "start", arguments) + res = receive_response(sock) + + if "error" in res: + return False + + state = res.get("state") + if state == "BLIND_SELECT": + return res.get("deck") == deck and res.get("stake") == stake + return False + + +def corrupt_file(path: Path) -> None: + """Corrupt a file for error testing.""" + path.write_bytes(b"CORRUPTED_SAVE_FILE_FOR_TESTING\x00\x01\x02") + + +def generate_fixture( + sock: socket.socket | None, + spec: FixtureSpec, + pbar: tqdm, +) -> bool: + """Generate a single fixture from its specification.""" + primary_path = spec.paths[0] - # Validate by loading it back - send_request(sock, "load", {"path": str(load_fixture)}) + try: + # Setup game state + if spec.setup: + if not sock: + pbar.write(f" Error: {spec.name} requires socket connection") + return False + if not spec.setup(sock): + pbar.write(f" Error: {spec.name} setup failed") + return False + + # Save fixture + primary_path.parent.mkdir(parents=True, exist_ok=True) + assert sock, "Socket connection required for save" + + if spec.setup: # Game-based fixture + send_request(sock, "save", {"path": str(primary_path)}) + res = receive_response(sock) + if not res.get("success"): + error = res.get("error", "Unknown error") + pbar.write(f" Error: {spec.name} save failed: {error}") + return False + else: # Non-game fixture (created by post_process) + primary_path.touch() + + # Copy to additional paths + for dest_path in spec.paths[1:]: + dest_path.parent.mkdir(parents=True, exist_ok=True) + dest_path.write_bytes(primary_path.read_bytes()) + + # Post-processing + if spec.post_process: + for path in spec.paths: + spec.post_process(path) + + # Validation + if spec.validate and spec.setup and sock: + send_request(sock, "load", {"path": str(primary_path)}) load_response = receive_response(sock) + if not load_response.get("success"): + pbar.write(f" Warning: {spec.name} validation failed") - if load_response.get("success"): - print(f" Validated: fixtures load successfully") - else: - print(f" Warning: fixtures generated but failed to load") - print(f" Error: {load_response.get('error', 'Unknown error')}") - else: - print(f" Failed to generate fixtures") - - -def generate_corrupted() -> None: - """Generate corrupted.jkr fixture for error testing. - - Creates an intentionally corrupted .jkr file in load/ directory to test - EXEC_INVALID_SAVE_FORMAT error handling in the load endpoint. - """ - fixture_path = FIXTURES_DIR / "load" / "corrupted.jkr" - print(f"Generating {fixture_path}...") - - # Write invalid/truncated data that won't decompress correctly - corrupted_data = b"CORRUPTED_SAVE_FILE_FOR_TESTING\x00\x01\x02" - - fixture_path.write_bytes(corrupted_data) - print(f" Generated {fixture_path}") - print(f" File size: {fixture_path.stat().st_size} bytes") - print(f" This file is intentionally corrupted for error testing") + return True - -def main() -> None: - """Main entry point for fixture generation.""" + except Exception as e: + pbar.write(f" Error: {spec.name} failed: {e}") + return False + + +def build_fixtures() -> list[FixtureSpec]: + """Build fixture specifications.""" + return [ + FixtureSpec( + name="Initial state (BLIND_SELECT)", + paths=[ + FIXTURES_DIR / "save" / "state-BLIND_SELECT.jkr", + FIXTURES_DIR / "load" / "state-BLIND_SELECT.jkr", + FIXTURES_DIR / "menu" / "state-BLIND_SELECT.jkr", + FIXTURES_DIR / "health" / "state-BLIND_SELECT.jkr", + FIXTURES_DIR / "start" / "state-BLIND_SELECT.jkr", + FIXTURES_DIR + / "gamestate" + / "state-BLIND_SELECT--round_num-0--deck-RED--stake-WHITE.jkr", + ], + setup=lambda sock: start_new_game(sock, deck="RED", stake="WHITE"), + ), + FixtureSpec( + name="load/corrupted.jkr", + paths=[FIXTURES_DIR / "load" / "corrupted.jkr"], + setup=None, + validate=False, + post_process=corrupt_file, + ), + ] + + +def should_generate(spec: FixtureSpec, regenerated: set[str]) -> bool: + """Check if fixture should be generated (dependencies or missing files).""" + # Check dependencies - if any dependency was regenerated, regenerate this too + if any(dep in regenerated for dep in spec.depends_on): + return True + + # Check if any path is missing + return not all(path.exists() for path in spec.paths) + + +def main() -> int: + """Main entry point.""" print("BalatroBot Fixture Generator") - print(f"Connecting to {HOST}:{PORT}") - print() + print(f"Connecting to {HOST}:{PORT}\n") + + fixtures = build_fixtures() + + # Check existing fixtures + existing = [spec for spec in fixtures if all(path.exists() for path in spec.paths)] + + if existing: + print(f"Found {len(existing)} existing fixture(s)") + response = input("Delete all existing fixtures and regenerate? [y/N]: ") + if response.lower() == "y": + for spec in existing: + for path in spec.paths: + if path.exists(): + path.unlink() + print("Deleted existing fixtures\n") + else: + print("Will skip existing fixtures\n") try: - generate_start_fixtures() - print() - generate_corrupted() - print() - - print("Fixture generation complete!") - print(f"Fixtures organized in: {FIXTURES_DIR}/") - print(" - save/start.jkr") - print(" - load/start.jkr") - print(" - load/corrupted.jkr") + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.connect((HOST, PORT)) + + regenerated: set[str] = set() + success = 0 + skipped = 0 + failed = 0 + + with tqdm( + total=len(fixtures), desc="Generating fixtures", unit="fixture" + ) as pbar: + for spec in fixtures: + if should_generate(spec, regenerated): + if generate_fixture(sock, spec, pbar): + regenerated.add(spec.name) + success += 1 + else: + failed += 1 + else: + pbar.write(f" Skipped: {spec.name} (already exists)") + skipped += 1 + pbar.update(1) + + print(f"\nSummary: {success} generated, {skipped} skipped, {failed} failed") + + if failed > 0: + return 1 + + return 0 except ConnectionRefusedError: print(f"Error: Could not connect to Balatro at {HOST}:{PORT}") print("Make sure Balatro is running with BalatroBot mod loaded") return 1 + except socket.timeout: + print(f"Error: Connection timeout to Balatro at {HOST}:{PORT}") + return 1 except Exception as e: - print(f"Unexpected error: {e}") + print(f"Error: {e}") return 1 - return 0 - if __name__ == "__main__": exit(main()) From d4f4b4cbc19fe05263fcb273dfb422b0aac949ae Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Mon, 17 Nov 2025 18:08:34 +0100 Subject: [PATCH 064/230] chore: lock uv.lock --- uv.lock | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/uv.lock b/uv.lock index 8ba7e0f..723697f 100644 --- a/uv.lock +++ b/uv.lock @@ -56,6 +56,7 @@ dev = [ { name = "pytest-rerunfailures" }, { name = "pytest-xdist", extra = ["psutil"] }, { name = "ruff" }, + { name = "tqdm" }, ] [package.metadata] @@ -75,6 +76,7 @@ dev = [ { 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]] @@ -872,6 +874,18 @@ 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" }, ] +[[package]] +name = "tqdm" +version = "4.67.1" +source = { registry = "https://pypi.org/simple" } +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/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-extensions" version = "4.14.1" From dd989d7d276acae04835b44b5b35fc2f681f6ef8 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Mon, 17 Nov 2025 18:09:18 +0100 Subject: [PATCH 065/230] chore: remove reruns pytest option from pyproject.toml --- pyproject.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index de5c905..e6dc911 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,8 +38,8 @@ lint.task-tags = ["FIX", "TODO", "HACK", "WARN", "PERF", "NOTE", "TEST"] [tool.pyright] typeCheckingMode = "basic" -[tool.pytest.ini_options] -addopts = "--reruns 5" +# [tool.pytest.ini_options] +# addopts = "--reruns 5" [dependency-groups] dev = [ From af50437a2f02bff0f69312e8cf03f15d2368a0cd Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Tue, 18 Nov 2025 21:53:03 +0100 Subject: [PATCH 066/230] fix(lua.endpoints): add check state loading for load endpoint --- src/lua/endpoints/load.lua | 29 ++++++++++++++++++++++++----- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/src/lua/endpoints/load.lua b/src/lua/endpoints/load.lua index 1355b92..3827536 100644 --- a/src/lua/endpoints/load.lua +++ b/src/lua/endpoints/load.lua @@ -64,7 +64,7 @@ return { -- Load using game's built-in functions G:delete_run() - G.SAVED_GAME = get_compressed(temp_filename) + G.SAVED_GAME = get_compressed(temp_filename) ---@diagnostic disable-line: undefined-global if G.SAVED_GAME == nil then send_response({ @@ -81,9 +81,28 @@ return { -- Clean up love.filesystem.remove(temp_filename) - send_response({ - success = true, - path = path, - }) + G.E_MANAGER:add_event(Event({ + no_delete = true, + trigger = "condition", + blocking = false, + func = function() + local done = false + 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 + + --- TODO: add other states here ... + + if done then + send_response({ + success = true, + path = path, + }) + end + return done + end, + })) end, } From 95d230c309f0ff68cf911dea0624b4337a5716f6 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Tue, 18 Nov 2025 21:53:58 +0100 Subject: [PATCH 067/230] fix(lua.endpoints): fix start endpoint check condition --- src/lua/endpoints/start.lua | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/lua/endpoints/start.lua b/src/lua/endpoints/start.lua index f407a84..3a258c4 100644 --- a/src/lua/endpoints/start.lua +++ b/src/lua/endpoints/start.lua @@ -139,11 +139,13 @@ return { G.E_MANAGER:add_event(Event({ no_delete = true, trigger = "condition", - blocking = true, + blocking = false, func = function() - -- Run is ready when we're in BLIND_SELECT state with a blind on deck - local done = G.STATE == G.STATES.BLIND_SELECT - + 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 = gamestate.get_gamestate() From a2ab93c24f40e3fd6b6ae19cd2f2cf04cdfc4f18 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Tue, 18 Nov 2025 21:55:41 +0100 Subject: [PATCH 068/230] refactor(lua.endpoints): update Blind type --- src/lua/utils/types.lua | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/lua/utils/types.lua b/src/lua/utils/types.lua index 0ff08e5..cf0a9e1 100644 --- a/src/lua/utils/types.lua +++ b/src/lua/utils/types.lua @@ -101,10 +101,11 @@ ---@field chips integer? Current chips scored in this round ---@class Blind ----@field name string Name of the blind (e.g., "Small", "Big", "The Wall") ----@field score integer Score requirement to beat this blind ----@field status "pending" | "current" | "completed" Status of the blind +---@field type "SMALL" | "BIG" | "BOSS" Type of the blind +---@field status "SELECT" | "CURRENT" | "UPCOMING" | "DEFEATED" | "SKIPPED" 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) From f6c3e27a43631d12d9f1ef3f117034ab771b77d1 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Tue, 18 Nov 2025 21:56:11 +0100 Subject: [PATCH 069/230] tests(fixtures): simplify fixture generation script --- tests/fixtures/generate.py | 174 ++++++++++++++----------------------- 1 file changed, 64 insertions(+), 110 deletions(-) diff --git a/tests/fixtures/generate.py b/tests/fixtures/generate.py index 36a1fac..71ad025 100644 --- a/tests/fixtures/generate.py +++ b/tests/fixtures/generate.py @@ -14,9 +14,8 @@ import json import socket -from dataclasses import dataclass, field +from dataclasses import dataclass from pathlib import Path -from typing import Callable from tqdm import tqdm @@ -30,26 +29,25 @@ class FixtureSpec: """Specification for a single fixture.""" - name: str # Display name paths: list[Path] # Output paths (first is primary, rest are copies) - setup: Callable[[socket.socket], bool] | None = None # Game state setup - validate: bool = True # Whether to validate by loading - post_process: Callable[[Path], None] | None = ( - None # Post-processing (e.g., corruption) - ) - depends_on: list[str] = field(default_factory=list) # Dependencies + setup: list[tuple[str, dict]] # Sequence of API calls: [(name, arguments), ...] -def send_request(sock: socket.socket, name: str, arguments: dict) -> None: - """Send a JSON request to the Balatro server.""" +def api(sock: socket.socket, name: str, arguments: dict) -> dict: + """Send API call to Balatro and return response. + + Args: + sock: Connected socket to Balatro server. + name: API endpoint name. + arguments: API call arguments. + + Returns: + Response dictionary from server. + """ request = {"name": name, "arguments": arguments} message = json.dumps(request) + "\n" sock.sendall(message.encode()) - -def receive_response(sock: socket.socket, timeout: float = 3.0) -> dict: - """Receive and parse JSON response from server.""" - sock.settimeout(timeout) response = sock.recv(BUFFER_SIZE) decoded = response.decode() first_newline = decoded.find("\n") @@ -60,93 +58,39 @@ def receive_response(sock: socket.socket, timeout: float = 3.0) -> dict: return json.loads(first_message) -def start_new_game( - sock: socket.socket, - deck: str = "RED", - stake: str = "WHITE", - seed: str | None = None, -) -> bool: - """Start a new game with specified deck and stake.""" - # Ensure menu state - send_request(sock, "menu", {}) - res = receive_response(sock) - if "error" in res or res.get("state") != "MENU": - return False - - # Start game - arguments = {"deck": deck, "stake": stake} - if seed: - arguments["seed"] = seed - send_request(sock, "start", arguments) - res = receive_response(sock) - - if "error" in res: - return False - - state = res.get("state") - if state == "BLIND_SELECT": - return res.get("deck") == deck and res.get("stake") == stake - return False - - def corrupt_file(path: Path) -> None: """Corrupt a file for error testing.""" + path.parent.mkdir(parents=True, exist_ok=True) path.write_bytes(b"CORRUPTED_SAVE_FILE_FOR_TESTING\x00\x01\x02") def generate_fixture( - sock: socket.socket | None, + sock: socket.socket, spec: FixtureSpec, pbar: tqdm, ) -> bool: """Generate a single fixture from its specification.""" primary_path = spec.paths[0] + relative_path = primary_path.relative_to(FIXTURES_DIR) try: - # Setup game state - if spec.setup: - if not sock: - pbar.write(f" Error: {spec.name} requires socket connection") - return False - if not spec.setup(sock): - pbar.write(f" Error: {spec.name} setup failed") - return False + # Execute API call sequence + for endpoint, arguments in spec.setup: + api(sock, endpoint, arguments) # Save fixture primary_path.parent.mkdir(parents=True, exist_ok=True) - assert sock, "Socket connection required for save" - - if spec.setup: # Game-based fixture - send_request(sock, "save", {"path": str(primary_path)}) - res = receive_response(sock) - if not res.get("success"): - error = res.get("error", "Unknown error") - pbar.write(f" Error: {spec.name} save failed: {error}") - return False - else: # Non-game fixture (created by post_process) - primary_path.touch() + api(sock, "save", {"path": str(primary_path)}) # Copy to additional paths for dest_path in spec.paths[1:]: dest_path.parent.mkdir(parents=True, exist_ok=True) dest_path.write_bytes(primary_path.read_bytes()) - # Post-processing - if spec.post_process: - for path in spec.paths: - spec.post_process(path) - - # Validation - if spec.validate and spec.setup and sock: - send_request(sock, "load", {"path": str(primary_path)}) - load_response = receive_response(sock) - if not load_response.get("success"): - pbar.write(f" Warning: {spec.name} validation failed") - return True except Exception as e: - pbar.write(f" Error: {spec.name} failed: {e}") + pbar.write(f" Error: {relative_path} failed: {e}") return False @@ -154,7 +98,6 @@ def build_fixtures() -> list[FixtureSpec]: """Build fixture specifications.""" return [ FixtureSpec( - name="Initial state (BLIND_SELECT)", paths=[ FIXTURES_DIR / "save" / "state-BLIND_SELECT.jkr", FIXTURES_DIR / "load" / "state-BLIND_SELECT.jkr", @@ -162,28 +105,47 @@ def build_fixtures() -> list[FixtureSpec]: FIXTURES_DIR / "health" / "state-BLIND_SELECT.jkr", FIXTURES_DIR / "start" / "state-BLIND_SELECT.jkr", FIXTURES_DIR + / "skip" + / "state-BLIND_SELECT--blinds.small.status-SELECT.jkr", + FIXTURES_DIR / "gamestate" / "state-BLIND_SELECT--round_num-0--deck-RED--stake-WHITE.jkr", ], - setup=lambda sock: start_new_game(sock, deck="RED", stake="WHITE"), + setup=[ + ("menu", {}), + ("start", {"deck": "RED", "stake": "WHITE"}), + ], + ), + FixtureSpec( + paths=[ + FIXTURES_DIR + / "skip" + / "state-BLIND_SELECT--blinds.big.status-SELECT.jkr", + ], + setup=[ + ("menu", {}), + ("start", {"deck": "RED", "stake": "WHITE"}), + ("skip", {}), + ], ), FixtureSpec( - name="load/corrupted.jkr", - paths=[FIXTURES_DIR / "load" / "corrupted.jkr"], - setup=None, - validate=False, - post_process=corrupt_file, + paths=[ + FIXTURES_DIR + / "skip" + / "state-BLIND_SELECT--blinds.boss.status-SELECT.jkr", + ], + setup=[ + ("menu", {}), + ("start", {"deck": "RED", "stake": "WHITE"}), + ("skip", {}), + ("skip", {}), + ], ), ] -def should_generate(spec: FixtureSpec, regenerated: set[str]) -> bool: - """Check if fixture should be generated (dependencies or missing files).""" - # Check dependencies - if any dependency was regenerated, regenerate this too - if any(dep in regenerated for dep in spec.depends_on): - return True - - # Check if any path is missing +def should_generate(spec: FixtureSpec) -> bool: + """Check if fixture should be generated (any path missing).""" return not all(path.exists() for path in spec.paths) @@ -194,26 +156,10 @@ def main() -> int: fixtures = build_fixtures() - # Check existing fixtures - existing = [spec for spec in fixtures if all(path.exists() for path in spec.paths)] - - if existing: - print(f"Found {len(existing)} existing fixture(s)") - response = input("Delete all existing fixtures and regenerate? [y/N]: ") - if response.lower() == "y": - for spec in existing: - for path in spec.paths: - if path.exists(): - path.unlink() - print("Deleted existing fixtures\n") - else: - print("Will skip existing fixtures\n") - try: with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: sock.connect((HOST, PORT)) - regenerated: set[str] = set() success = 0 skipped = 0 failed = 0 @@ -222,17 +168,25 @@ def main() -> int: total=len(fixtures), desc="Generating fixtures", unit="fixture" ) as pbar: for spec in fixtures: - if should_generate(spec, regenerated): + if should_generate(spec): if generate_fixture(sock, spec, pbar): - regenerated.add(spec.name) success += 1 else: failed += 1 else: - pbar.write(f" Skipped: {spec.name} (already exists)") + relative_path = spec.paths[0].relative_to(FIXTURES_DIR) + pbar.write(f" Skipped: {relative_path}") skipped += 1 pbar.update(1) + # Go back to menu state + api(sock, "menu", {}) + + # Generate corrupted fixture + corrupted_path = FIXTURES_DIR / "load" / "corrupted.jkr" + corrupt_file(corrupted_path) + success += 1 + print(f"\nSummary: {success} generated, {skipped} skipped, {failed} failed") if failed > 0: From 3a2722e5d5e344b2370c394b24ac9167438967a4 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Tue, 18 Nov 2025 21:58:42 +0100 Subject: [PATCH 070/230] feat(lua.endpoints): add skip endpoint --- src/lua/endpoints/skip.lua | 68 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 src/lua/endpoints/skip.lua diff --git a/src/lua/endpoints/skip.lua b/src/lua/endpoints/skip.lua new file mode 100644 index 0000000..7550a62 --- /dev/null +++ b/src/lua/endpoints/skip.lua @@ -0,0 +1,68 @@ +-- src/lua/endpoints/skip.lua +-- Skip Endpoint +-- +-- Skip the current blind (Small or Big only, not Boss) + +local gamestate = assert(SMODS.load_file("src/lua/utils/gamestate.lua"))() +local errors = assert(SMODS.load_file("src/lua/utils/errors.lua"))() + +---@type Endpoint +return { + name = "skip", + description = "Skip the current blind (Small or Big only, not Boss)", + schema = {}, + requires_state = { G.STATES.BLIND_SELECT }, + + ---@param args table The arguments (none required) + ---@param send_response fun(response: table) Callback to send response + execute = function(args, 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 = 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({ + error = "Cannot skip Boss blind", + error_code = errors.GAME_INVALID_STATE, + }) + 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] + 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({ + no_delete = true, + trigger = "condition", + blocking = true, + func = function() + local blinds = 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 = gamestate.get_gamestate() + send_response(state_data) + end + + return done + end, + })) + end, +} From 68dee0e8d43d3c3fae3949989b429de4398d62d4 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Tue, 18 Nov 2025 21:58:52 +0100 Subject: [PATCH 071/230] test(lua.endpoints): add skip endpoint tests --- tests/lua/endpoints/test_skip.py | 69 ++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 tests/lua/endpoints/test_skip.py diff --git a/tests/lua/endpoints/test_skip.py b/tests/lua/endpoints/test_skip.py new file mode 100644 index 0000000..b6035a7 --- /dev/null +++ b/tests/lua/endpoints/test_skip.py @@ -0,0 +1,69 @@ +"""Tests for src/lua/endpoints/skip.lua""" + +import socket +from typing import Any + +import pytest + +from tests.lua.conftest import api, assert_error_response, get_fixture_path + + +@pytest.mark.skip(reason="Skipping test") +def verify_skip_response(response: dict[str, Any]) -> None: + """Verify that skip response has expected fields.""" + # Verify state field + assert "state" in response + assert isinstance(response["state"], str) + assert response["state"] == "BLIND_SELECT" + + # Verify blinds field exists + assert "blinds" in response + assert isinstance(response["blinds"], dict) + + +class TestSkipEndpoint: + """Test basic skip endpoint functionality.""" + + def test_skip_small_blind(self, client: socket.socket) -> None: + """Test skipping Small blind in BLIND_SELECT state.""" + save = "state-BLIND_SELECT--blinds.small.status-SELECT.jkr" + api(client, "load", {"path": str(get_fixture_path("skip", save))}) + response = api(client, "skip", {}) + verify_skip_response(response) + assert response["blinds"]["small"]["status"] == "SKIPPED" + assert response["blinds"]["big"]["status"] == "SELECT" + + def test_skip_big_blind(self, client: socket.socket) -> None: + """Test skipping Big blind in BLIND_SELECT state.""" + save = "state-BLIND_SELECT--blinds.big.status-SELECT.jkr" + api(client, "load", {"path": str(get_fixture_path("skip", save))}) + response = api(client, "skip", {}) + verify_skip_response(response) + assert response["blinds"]["big"]["status"] == "SKIPPED" + assert response["blinds"]["boss"]["status"] == "SELECT" + + def test_skip_big_boss(self, client: socket.socket) -> None: + """Test skipping Boss in BLIND_SELECT state.""" + save = "state-BLIND_SELECT--blinds.boss.status-SELECT.jkr" + api(client, "load", {"path": str(get_fixture_path("skip", save))}) + response = api(client, "skip", {}) + assert_error_response( + response, + expected_error_code="GAME_INVALID_STATE", + expected_message_contains="Cannot skip Boss blind", + ) + + +class TestSkipEndpointStateRequirements: + """Test skip endpoint state requirements.""" + + def test_skip_from_MENU(self, client: socket.socket): + """Test that skip fails when not in BLIND_SELECT state.""" + response = api(client, "menu", {}) + assert response["state"] == "MENU" + response = api(client, "skip", {}) + assert_error_response( + response, + expected_error_code="STATE_INVALID_STATE", + expected_message_contains="Endpoint 'skip' requires one of these states:", + ) From b02b6753798e0d01f025403be2878bbb6560579e Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Tue, 18 Nov 2025 21:59:26 +0100 Subject: [PATCH 072/230] feat: add new skip endpoint to balatrobot.lua --- balatrobot.lua | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/balatrobot.lua b/balatrobot.lua index 0c2b936..2cf2880 100644 --- a/balatrobot.lua +++ b/balatrobot.lua @@ -6,12 +6,19 @@ BB_SETTINGS.setup() -- 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", - "src/lua/endpoints/gamestate.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", -- If debug mode is enabled, debugger.lua will load test endpoints } From 5e4d07196ba78f2d839aa85ec36ff4e0de126c4b Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Tue, 18 Nov 2025 22:10:19 +0100 Subject: [PATCH 073/230] fix(utils): improve gamestate get_blinds_info and make it public fix(utils): improve gamestate get_blinds_info and make it public --- src/lua/utils/gamestate.lua | 303 ++++++++++++++++++++++-------------- 1 file changed, 189 insertions(+), 114 deletions(-) diff --git a/src/lua/utils/gamestate.lua b/src/lua/utils/gamestate.lua index 70d2285..c17f0e7 100644 --- a/src/lua/utils/gamestate.lua +++ b/src/lua/utils/gamestate.lua @@ -358,34 +358,152 @@ local function extract_round_info() end -- ========================================================================== --- Blind Information (adapted from old utils) +-- 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 -local function get_blinds_info() +function gamestate.get_blinds_info() + -- Initialize with default structure matching the Blind type local blinds = { small = { - name = "Small", - score = 0, - status = "pending", + type = "SMALL", + status = "UPCOMING", + name = "", effect = "", + score = 0, tag_name = "", tag_effect = "", }, big = { - name = "Big", - score = 0, - status = "pending", + type = "BIG", + status = "UPCOMING", + name = "", effect = "", + score = 0, tag_name = "", tag_effect = "", }, boss = { + type = "BOSS", + status = "UPCOMING", name = "", - score = 0, - status = "pending", effect = "", + score = 0, tag_name = "", tag_effect = "", }, @@ -399,122 +517,79 @@ local function get_blinds_info() 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) - if G.GAME.round_resets.blind_states and G.GAME.round_resets.blind_states.Small then - local status = G.GAME.round_resets.blind_states.Small - if status == "Defeated" or status == "Skipped" then - blinds.small.status = "completed" - elseif status == "Current" or status == "Select" then - blinds.small.status = "current" + -- 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 - end - -- Big blind (1.5x multiplier) - blinds.big.score = math.floor(base_amount * 1.5 * ante_scaling) - if G.GAME.round_resets.blind_states and G.GAME.round_resets.blind_states.Big then - local status = G.GAME.round_resets.blind_states.Big - if status == "Defeated" or status == "Skipped" then - blinds.big.status = "completed" - elseif status == "Current" or status == "Select" then - blinds.big.status = "current" + -- 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 - -- Boss blind - local boss_choice = G.GAME.round_resets.blind_choices and G.GAME.round_resets.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 "" - blinds.boss.score = math.floor(base_amount * (boss_blind.mult or 2) * ante_scaling) + -- ==================== + -- 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) - if G.GAME.round_resets.blind_states and G.GAME.round_resets.blind_states.Boss then - local status = G.GAME.round_resets.blind_states.Boss - if status == "Defeated" or status == "Skipped" then - blinds.boss.status = "completed" - elseif status == "Current" or status == "Select" then - blinds.boss.status = "current" - end + -- Set status + if blind_states.Big then + blinds.big.status = convert_status_to_enum(blind_states.Big) end - -- Get boss effect description - if boss_blind.key and localize then ---@diagnostic disable-line: undefined-global - 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) - if G.GAME.round_resets.blind_states and G.GAME.round_resets.blind_states.Boss then - local status = G.GAME.round_resets.blind_states.Boss - if status == "Defeated" or status == "Skipped" then - blinds.boss.status = "completed" - elseif status == "Current" or status == "Select" then - blinds.boss.status = "current" - 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 - -- Get tag information for Small and Big blinds - if G.GAME.round_resets.blind_tags and G.P_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 - if localize then ---@diagnostic disable-line: undefined-global - 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 - 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) - -- 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 - if localize then ---@diagnostic disable-line: undefined-global - 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 = tag_effect[2] .. " " .. tag_effect[2] - end - end - end + -- 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) @@ -586,7 +661,7 @@ function gamestate.get_gamestate() state_data.round = extract_round_info() -- Blinds info - state_data.blinds = get_blinds_info() + state_data.blinds = gamestate.get_blinds_info() end -- Always available areas From da89ca8f32f97db7624f1a1a3746bd1988cdca7f Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Tue, 18 Nov 2025 22:24:44 +0100 Subject: [PATCH 074/230] fix(lua.endpoints): fix type annotation for skip endpoint --- src/lua/endpoints/skip.lua | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/lua/endpoints/skip.lua b/src/lua/endpoints/skip.lua index 7550a62..ea4382d 100644 --- a/src/lua/endpoints/skip.lua +++ b/src/lua/endpoints/skip.lua @@ -13,9 +13,9 @@ return { schema = {}, requires_state = { G.STATES.BLIND_SELECT }, - ---@param args table The arguments (none required) + ---@param _ table The arguments (none required) ---@param send_response fun(response: table) Callback to send response - execute = function(args, send_response) + execute = function(_, send_response) sendDebugMessage("Init skip()", "BB.ENDPOINTS") -- Get the current blind on deck (similar to select endpoint) @@ -39,6 +39,9 @@ return { 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 From c5fdab60395817cb32ecec2643bb04e517798230 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Tue, 18 Nov 2025 22:37:41 +0100 Subject: [PATCH 075/230] test(fixtures): add fixtures generation for select endpoint --- tests/fixtures/generate.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/fixtures/generate.py b/tests/fixtures/generate.py index 71ad025..3920ffa 100644 --- a/tests/fixtures/generate.py +++ b/tests/fixtures/generate.py @@ -108,6 +108,9 @@ def build_fixtures() -> list[FixtureSpec]: / "skip" / "state-BLIND_SELECT--blinds.small.status-SELECT.jkr", FIXTURES_DIR + / "select" + / "state-BLIND_SELECT--blinds.small.status-SELECT.jkr", + FIXTURES_DIR / "gamestate" / "state-BLIND_SELECT--round_num-0--deck-RED--stake-WHITE.jkr", ], @@ -121,6 +124,9 @@ def build_fixtures() -> list[FixtureSpec]: FIXTURES_DIR / "skip" / "state-BLIND_SELECT--blinds.big.status-SELECT.jkr", + FIXTURES_DIR + / "select" + / "state-BLIND_SELECT--blinds.big.status-SELECT.jkr", ], setup=[ ("menu", {}), @@ -133,6 +139,9 @@ def build_fixtures() -> list[FixtureSpec]: FIXTURES_DIR / "skip" / "state-BLIND_SELECT--blinds.boss.status-SELECT.jkr", + FIXTURES_DIR + / "select" + / "state-BLIND_SELECT--blinds.boss.status-SELECT.jkr", ], setup=[ ("menu", {}), From 0414d335b8ba2c6fe2425174750119bcd20e05ab Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Tue, 18 Nov 2025 22:38:16 +0100 Subject: [PATCH 076/230] feat: add select endpoint to balatrobot.lua --- balatrobot.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/balatrobot.lua b/balatrobot.lua index 2cf2880..311fd10 100644 --- a/balatrobot.lua +++ b/balatrobot.lua @@ -18,7 +18,7 @@ BB_ENDPOINTS = { "src/lua/endpoints/start.lua", -- Blind selection endpoints "src/lua/endpoints/skip.lua", - -- "src/lua/endpoints/select.lua", + "src/lua/endpoints/select.lua", -- If debug mode is enabled, debugger.lua will load test endpoints } From 37e4decacce5eae9798936c5418cced684dfa430 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Tue, 18 Nov 2025 22:38:29 +0100 Subject: [PATCH 077/230] feat(lua.endpoints): add select endpoint --- src/lua/endpoints/select.lua | 40 ++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 src/lua/endpoints/select.lua diff --git a/src/lua/endpoints/select.lua b/src/lua/endpoints/select.lua new file mode 100644 index 0000000..bf87062 --- /dev/null +++ b/src/lua/endpoints/select.lua @@ -0,0 +1,40 @@ +local gamestate = assert(SMODS.load_file("src/lua/utils/gamestate.lua"))() + +---@type Endpoint +return { + name = "select", + description = "Select the current blind", + schema = {}, + requires_state = { G.STATES.BLIND_SELECT }, + + ---@param _ table The arguments (none required) + ---@param send_response fun(response: table) Callback to send response + execute = function(_, send_response) + -- 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({ + no_delete = true, + trigger = "condition", + blocking = false, + func = function() + local done = G.STATE == G.STATES.SELECTING_HAND + if done then + sendDebugMessage("select() completed", "BB.ENDPOINTS") + local state_data = gamestate.get_gamestate() + send_response(state_data) + end + return done + end, + })) + end, +} From 95ae7a9990966d1cb6aa3911705b5b7c953dbdca Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Tue, 18 Nov 2025 22:38:41 +0100 Subject: [PATCH 078/230] test(lua.endpoints): add test for select endpoint --- tests/lua/endpoints/test_select.py | 64 ++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 tests/lua/endpoints/test_select.py diff --git a/tests/lua/endpoints/test_select.py b/tests/lua/endpoints/test_select.py new file mode 100644 index 0000000..369fbf3 --- /dev/null +++ b/tests/lua/endpoints/test_select.py @@ -0,0 +1,64 @@ +"""Tests for src/lua/endpoints/select.lua""" + +import socket +from typing import Any + +from tests.lua.conftest import api, assert_error_response, get_fixture_path + + +def verify_select_response(response: dict[str, Any]) -> None: + """Verify that select response has expected fields.""" + # Verify state field - should transition to SELECTING_HAND after selecting blind + assert "state" in response + assert isinstance(response["state"], str) + assert response["state"] == "SELECTING_HAND" + + # Verify hand field exists + assert "hand" in response + assert isinstance(response["hand"], dict) + + +class TestSelectEndpoint: + """Test basic select endpoint functionality.""" + + def test_select_small_blind(self, client: socket.socket) -> None: + """Test selecting Small blind in BLIND_SELECT state.""" + save = "state-BLIND_SELECT--blinds.small.status-SELECT.jkr" + api(client, "load", {"path": str(get_fixture_path("select", save))}) + response = api(client, "select", {}) + verify_select_response(response) + # Verify we transitioned to SELECTING_HAND state + assert response["state"] == "SELECTING_HAND" + + def test_select_big_blind(self, client: socket.socket) -> None: + """Test selecting Big blind in BLIND_SELECT state.""" + save = "state-BLIND_SELECT--blinds.big.status-SELECT.jkr" + api(client, "load", {"path": str(get_fixture_path("select", save))}) + response = api(client, "select", {}) + verify_select_response(response) + # Verify we transitioned to SELECTING_HAND state + assert response["state"] == "SELECTING_HAND" + + def test_select_boss_blind(self, client: socket.socket) -> None: + """Test selecting Boss blind in BLIND_SELECT state.""" + save = "state-BLIND_SELECT--blinds.boss.status-SELECT.jkr" + api(client, "load", {"path": str(get_fixture_path("select", save))}) + response = api(client, "select", {}) + verify_select_response(response) + # Verify we transitioned to SELECTING_HAND state + assert response["state"] == "SELECTING_HAND" + + +class TestSelectEndpointStateRequirements: + """Test select endpoint state requirements.""" + + def test_select_from_MENU(self, client: socket.socket): + """Test that select fails when not in BLIND_SELECT state.""" + response = api(client, "menu", {}) + assert response["state"] == "MENU" + response = api(client, "select", {}) + assert_error_response( + response, + expected_error_code="STATE_INVALID_STATE", + expected_message_contains="Endpoint 'select' requires one of these states:", + ) From b1385abbcb9aeb71eb8e7428335a6366bb1ed508 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Tue, 18 Nov 2025 23:41:59 +0100 Subject: [PATCH 079/230] chore: remove unused code in skip endpoint test --- tests/lua/endpoints/test_skip.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/tests/lua/endpoints/test_skip.py b/tests/lua/endpoints/test_skip.py index b6035a7..9330a1a 100644 --- a/tests/lua/endpoints/test_skip.py +++ b/tests/lua/endpoints/test_skip.py @@ -3,12 +3,9 @@ import socket from typing import Any -import pytest - from tests.lua.conftest import api, assert_error_response, get_fixture_path -@pytest.mark.skip(reason="Skipping test") def verify_skip_response(response: dict[str, Any]) -> None: """Verify that skip response has expected fields.""" # Verify state field From 96e4e442d94e4feeea83cce65ae7ec81977fd220 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Wed, 19 Nov 2025 00:31:17 +0100 Subject: [PATCH 080/230] fix(lua.endpoints): add return after error in skip endpoint --- src/lua/endpoints/skip.lua | 1 + 1 file changed, 1 insertion(+) diff --git a/src/lua/endpoints/skip.lua b/src/lua/endpoints/skip.lua index ea4382d..ed317c9 100644 --- a/src/lua/endpoints/skip.lua +++ b/src/lua/endpoints/skip.lua @@ -31,6 +31,7 @@ return { error = "Cannot skip Boss blind", error_code = errors.GAME_INVALID_STATE, }) + return end -- Get the skip button from the tag element From d7bb418374a4db41ef4f7ff4a2c119f9f8787866 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Wed, 19 Nov 2025 13:34:30 +0100 Subject: [PATCH 081/230] feat(lua.endpoints): add types for args for various endpoints --- src/lua/endpoints/load.lua | 9 ++++++++- src/lua/endpoints/save.lua | 5 ++++- src/lua/endpoints/select.lua | 2 +- src/lua/endpoints/start.lua | 7 ++++++- 4 files changed, 19 insertions(+), 4 deletions(-) diff --git a/src/lua/endpoints/load.lua b/src/lua/endpoints/load.lua index 3827536..7e8c813 100644 --- a/src/lua/endpoints/load.lua +++ b/src/lua/endpoints/load.lua @@ -6,6 +6,9 @@ local nativefs = require("nativefs") local errors = assert(SMODS.load_file("src/lua/utils/errors.lua"))() +---@class Endpoint.Load.Args +---@field path string File path to the save file + ---@type Endpoint return { name = "load", @@ -22,7 +25,7 @@ return { requires_state = nil, - ---@param args table The arguments with 'path' field + ---@param args Endpoint.Load.Args The arguments with 'path' field ---@param send_response fun(response: table) Callback to send response execute = function(args, send_response) local path = args.path @@ -93,6 +96,10 @@ return { 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 + --- TODO: add other states here ... if done then diff --git a/src/lua/endpoints/save.lua b/src/lua/endpoints/save.lua index 294a19e..7873a04 100644 --- a/src/lua/endpoints/save.lua +++ b/src/lua/endpoints/save.lua @@ -6,6 +6,9 @@ local nativefs = require("nativefs") local errors = assert(SMODS.load_file("src/lua/utils/errors.lua"))() +---@class Endpoint.Save.Args +---@field path string File path for the save file + ---@type Endpoint return { name = "save", @@ -39,7 +42,7 @@ return { G.STATES.NEW_ROUND, -- 19 }, - ---@param args table The arguments with 'path' field + ---@param args Endpoint.Save.Args The arguments with 'path' field ---@param send_response fun(response: table) Callback to send response execute = function(args, send_response) local path = args.path diff --git a/src/lua/endpoints/select.lua b/src/lua/endpoints/select.lua index bf87062..a52c6da 100644 --- a/src/lua/endpoints/select.lua +++ b/src/lua/endpoints/select.lua @@ -27,7 +27,7 @@ return { trigger = "condition", blocking = false, func = function() - local done = G.STATE == G.STATES.SELECTING_HAND + local done = G.STATE == G.STATES.SELECTING_HAND and G.hand ~= nil if done then sendDebugMessage("select() completed", "BB.ENDPOINTS") local state_data = gamestate.get_gamestate() diff --git a/src/lua/endpoints/start.lua b/src/lua/endpoints/start.lua index 3a258c4..cc170d3 100644 --- a/src/lua/endpoints/start.lua +++ b/src/lua/endpoints/start.lua @@ -36,6 +36,11 @@ local STAKE_ENUM_TO_NUMBER = { GOLD = 8, } +---@class Endpoint.Run.Args +---@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 + ---@type Endpoint return { name = "start", @@ -62,7 +67,7 @@ return { requires_state = { G.STATES.MENU }, - ---@param args table The arguments (deck, stake, seed?) + ---@param args Endpoint.Run.Args The arguments (deck, stake, seed?) ---@param send_response fun(response: table) Callback to send response execute = function(args, send_response) sendDebugMessage("Init start()", "BB.ENDPOINTS") From 54561d293dbf81c4a10fdc0ea89e714797d7f397 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Wed, 19 Nov 2025 14:09:47 +0100 Subject: [PATCH 082/230] feat(core): add boolean and table types to validator --- src/lua/core/validator.lua | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/lua/core/validator.lua b/src/lua/core/validator.lua index 1cdb051..26a169e 100644 --- a/src/lua/core/validator.lua +++ b/src/lua/core/validator.lua @@ -13,7 +13,9 @@ -- Supported Types: -- - string: Basic string type -- - integer: Integer number (validated with math.floor check) +-- - boolean: Boolean type (true/false) -- - array: Array of items (validated with sequential numeric indices) +-- - table: Generic table type (non-array tables) -- -- Range/Length Validation: -- Min/max validation is NOT handled by the validator. Endpoints implement @@ -24,7 +26,7 @@ local errors = assert(SMODS.load_file("src/lua/utils/errors.lua"))() ---@class SchemaField ----@field type "string"|"integer"|"array" The field type (only string, integer, and array supported) +---@field type "string"|"integer"|"array"|"boolean"|"table" The field type ---@field required boolean? Whether the field is required ---@field items "integer"? Type of array items (only "integer" supported, only for array type) ---@field description string Description of the field (required) @@ -75,8 +77,13 @@ local function validate_field(field_name, value, field_schema) if not is_array(value) then return false, "Field '" .. field_name .. "' must be an array", errors.SCHEMA_INVALID_TYPE end + elseif expected_type == "table" then + -- Empty tables are allowed, non-empty arrays are rejected + if type(value) ~= "table" or (next(value) ~= nil and is_array(value)) then + return false, "Field '" .. field_name .. "' must be a table", errors.SCHEMA_INVALID_TYPE + end else - -- Standard Lua types: string, number, boolean, table + -- Standard Lua types: string, boolean if type(value) ~= expected_type then return false, "Field '" .. field_name .. "' must be of type " .. expected_type, errors.SCHEMA_INVALID_TYPE end From c38e1f9d15b820bc028c0bd0e22daa5175575962 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Wed, 19 Nov 2025 14:10:07 +0100 Subject: [PATCH 083/230] test(lua.core): add test for boolean type validation --- src/lua/endpoints/tests/validation.lua | 12 ++- tests/lua/core/test_validator.py | 116 ++++++++++++++++++++++++- 2 files changed, 126 insertions(+), 2 deletions(-) diff --git a/src/lua/endpoints/tests/validation.lua b/src/lua/endpoints/tests/validation.lua index c70115e..e8b5812 100644 --- a/src/lua/endpoints/tests/validation.lua +++ b/src/lua/endpoints/tests/validation.lua @@ -2,7 +2,7 @@ -- Comprehensive Validation Test Endpoint -- -- Endpoint with schema for testing simplified validator capabilities: --- - Type validation (string, integer, array) +-- - Type validation (string, integer, boolean, array, table) -- - Required field validation -- - Array item type validation (integer arrays only) @@ -31,11 +31,21 @@ return { description = "Optional integer field for type validation", }, + boolean_field = { + type = "boolean", + description = "Optional boolean field for type validation", + }, + array_field = { type = "array", description = "Optional array field for type validation", }, + table_field = { + type = "table", + description = "Optional table field for type validation", + }, + -- Array item type validation array_of_integers = { type = "array", diff --git a/tests/lua/core/test_validator.py b/tests/lua/core/test_validator.py index 25197ad..2126234 100644 --- a/tests/lua/core/test_validator.py +++ b/tests/lua/core/test_validator.py @@ -2,7 +2,7 @@ # Comprehensive tests for src/lua/core/validator.lua # # Tests validation scenarios through the dispatcher using the test_validation endpoint: -# - Type validation (string, integer, array) +# - Type validation (string, integer, boolean, array, table) # - Required field validation # - Array item type validation (integer arrays only) # - Error codes and messages @@ -139,6 +139,118 @@ def test_invalid_array_type_string(self, client: socket.socket) -> None: expected_message_contains="array_field", ) + def test_valid_boolean_type_true(self, client: socket.socket) -> None: + """Test that boolean true passes validation.""" + response = api( + client, + "test_validation", + { + "required_field": "test", + "boolean_field": True, + }, + ) + assert_success_response(response) + + def test_valid_boolean_type_false(self, client: socket.socket) -> None: + """Test that boolean false passes validation.""" + response = api( + client, + "test_validation", + { + "required_field": "test", + "boolean_field": False, + }, + ) + assert_success_response(response) + + def test_invalid_boolean_type_string(self, client: socket.socket) -> 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, + expected_error_code="SCHEMA_INVALID_TYPE", + expected_message_contains="boolean_field", + ) + + def test_invalid_boolean_type_number(self, client: socket.socket) -> 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, + expected_error_code="SCHEMA_INVALID_TYPE", + expected_message_contains="boolean_field", + ) + + def test_valid_table_type(self, client: socket.socket) -> 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_success_response(response) + + def test_valid_table_type_empty(self, client: socket.socket) -> None: + """Test that empty table passes validation.""" + response = api( + client, + "test_validation", + { + "required_field": "test", + "table_field": {}, + }, + ) + assert_success_response(response) + + def test_invalid_table_type_array(self, client: socket.socket) -> 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, + expected_error_code="SCHEMA_INVALID_TYPE", + expected_message_contains="table_field", + ) + + def test_invalid_table_type_string(self, client: socket.socket) -> None: + """Test that string fails table validation.""" + response = api( + client, + "test_validation", + { + "required_field": "test", + "table_field": "not a table", + }, + ) + assert_error_response( + response, + expected_error_code="SCHEMA_INVALID_TYPE", + expected_message_contains="table_field", + ) + # ============================================================================ # Test: Required Field Validation @@ -293,7 +405,9 @@ def test_all_fields_provided(self, client: socket.socket) -> None: "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], }, ) From b80af606157b9851d9b26b6a5fffe0a4a48e264a Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Thu, 20 Nov 2025 11:25:37 +0100 Subject: [PATCH 084/230] fix(utils): add won field to gamestate --- src/lua/utils/gamestate.lua | 1 + src/lua/utils/types.lua | 1 + 2 files changed, 2 insertions(+) diff --git a/src/lua/utils/gamestate.lua b/src/lua/utils/gamestate.lua index c17f0e7..6624aa7 100644 --- a/src/lua/utils/gamestate.lua +++ b/src/lua/utils/gamestate.lua @@ -622,6 +622,7 @@ function gamestate.get_gamestate() 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 diff --git a/src/lua/utils/types.lua b/src/lua/utils/types.lua index cf0a9e1..3f16ae6 100644 --- a/src/lua/utils/types.lua +++ b/src/lua/utils/types.lua @@ -32,6 +32,7 @@ ---@field hand Area? Hand area (available during playing phase) ---@field shop Area? Shop area (available during shop phase) ---@field vouchers Area? Vouchers area (available during shop phase) +---@field won boolean? Whether the game has been won ---@alias Deck ---| "RED" # +1 discard every round From 926701c56873c31b6c652c269efadf396ba2adeb Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Thu, 20 Nov 2025 20:52:33 +0100 Subject: [PATCH 085/230] chore(lua.utils): be more explicit about blinds types --- src/lua/utils/types.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lua/utils/types.lua b/src/lua/utils/types.lua index 3f16ae6..a18644c 100644 --- a/src/lua/utils/types.lua +++ b/src/lua/utils/types.lua @@ -26,7 +26,7 @@ ---@field used_vouchers table? Vouchers used (name -> description) ---@field hands table? Poker hands information ---@field round Round? Current round state ----@field blinds table? Blind information (keys: "small", "big", "boss") +---@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) From cc64ec9e5b81368b0154b1fc103e8c59b4a47f01 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Thu, 20 Nov 2025 20:53:03 +0100 Subject: [PATCH 086/230] test(lua): set timout to 5 seconds in the api function --- tests/lua/conftest.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/lua/conftest.py b/tests/lua/conftest.py index e9ae45f..4f0a46e 100644 --- a/tests/lua/conftest.py +++ b/tests/lua/conftest.py @@ -57,6 +57,7 @@ def api( client: socket.socket, name: str, arguments: dict = {}, + timeout: int = 5, ) -> dict[str, Any]: """Send an API call to the Balatro game and get the response. @@ -70,6 +71,7 @@ def api( """ payload = {"name": name, "arguments": arguments} client.send(json.dumps(payload).encode() + b"\n") + client.settimeout(timeout) response = client.recv(BUFFER_SIZE) gamestate = json.loads(response.decode().strip()) return gamestate From 777e1311267f07d48b1c9f51e38c1d7f8d05ca1d Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Thu, 20 Nov 2025 20:54:40 +0100 Subject: [PATCH 087/230] style(lua.endpoints): use BB_GAMESTATE and BB_ERRORS globals variables --- balatrobot.lua | 4 ++++ src/lua/endpoints/gamestate.lua | 4 +--- src/lua/endpoints/load.lua | 9 ++++----- src/lua/endpoints/menu.lua | 4 +--- src/lua/endpoints/save.lua | 7 +++---- src/lua/endpoints/select.lua | 4 +--- src/lua/endpoints/skip.lua | 11 ++++------- src/lua/endpoints/start.lua | 11 ++++------- 8 files changed, 22 insertions(+), 32 deletions(-) diff --git a/balatrobot.lua b/balatrobot.lua index 311fd10..8ee6369 100644 --- a/balatrobot.lua +++ b/balatrobot.lua @@ -32,6 +32,10 @@ end 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"))() +BB_ERRORS = assert(SMODS.load_file("src/lua/utils/errors.lua"))() + -- Initialize Server local server_success = BB_SERVER.init() if not server_success then diff --git a/src/lua/endpoints/gamestate.lua b/src/lua/endpoints/gamestate.lua index 0835b78..756c2af 100644 --- a/src/lua/endpoints/gamestate.lua +++ b/src/lua/endpoints/gamestate.lua @@ -4,8 +4,6 @@ -- Returns the current game state extracted via the gamestate utility -- Provides a simplified view of the game optimized for bot decision-making -local gamestate = assert(SMODS.load_file("src/lua/utils/gamestate.lua"))() - ---@type Endpoint return { name = "gamestate", @@ -20,7 +18,7 @@ return { ---@param send_response fun(response: table) Callback to send response execute = function(_, send_response) -- Get current game state - local state_data = gamestate.get_gamestate() + local state_data = BB_GAMESTATE.get_gamestate() -- Return the game state send_response(state_data) diff --git a/src/lua/endpoints/load.lua b/src/lua/endpoints/load.lua index 7e8c813..01653c8 100644 --- a/src/lua/endpoints/load.lua +++ b/src/lua/endpoints/load.lua @@ -4,7 +4,6 @@ -- Loads a saved game run state from a file using nativefs local nativefs = require("nativefs") -local errors = assert(SMODS.load_file("src/lua/utils/errors.lua"))() ---@class Endpoint.Load.Args ---@field path string File path to the save file @@ -35,7 +34,7 @@ return { if not file_info or file_info.type ~= "file" then send_response({ error = "File not found: '" .. path .. "'", - error_code = errors.EXEC_FILE_NOT_FOUND, + error_code = BB_ERRORS.EXEC_FILE_NOT_FOUND, }) return end @@ -46,7 +45,7 @@ return { if not compressed_data then send_response({ error = "Failed to read save file", - error_code = errors.EXEC_INTERNAL_ERROR, + error_code = BB_ERRORS.EXEC_INTERNAL_ERROR, }) return end @@ -60,7 +59,7 @@ return { if not write_success then send_response({ error = "Failed to prepare save file for loading", - error_code = errors.EXEC_INTERNAL_ERROR, + error_code = BB_ERRORS.EXEC_INTERNAL_ERROR, }) return end @@ -72,7 +71,7 @@ return { if G.SAVED_GAME == nil then send_response({ error = "Invalid save file format", - error_code = errors.EXEC_INVALID_SAVE_FORMAT, + error_code = BB_ERRORS.EXEC_INVALID_SAVE_FORMAT, }) love.filesystem.remove(temp_filename) return diff --git a/src/lua/endpoints/menu.lua b/src/lua/endpoints/menu.lua index eb93901..f2f4026 100644 --- a/src/lua/endpoints/menu.lua +++ b/src/lua/endpoints/menu.lua @@ -3,8 +3,6 @@ -- -- Returns to the main menu from any game state -local gamestate = assert(SMODS.load_file("src/lua/utils/gamestate.lua"))() - ---@type Endpoint return { name = "menu", @@ -31,7 +29,7 @@ return { if done then sendDebugMessage("Return menu()", "BB.ENDPOINTS") - local state_data = gamestate.get_gamestate() + local state_data = BB_GAMESTATE.get_gamestate() send_response(state_data) end diff --git a/src/lua/endpoints/save.lua b/src/lua/endpoints/save.lua index 7873a04..82fff21 100644 --- a/src/lua/endpoints/save.lua +++ b/src/lua/endpoints/save.lua @@ -4,7 +4,6 @@ -- Saves the current game run state to a file using nativefs local nativefs = require("nativefs") -local errors = assert(SMODS.load_file("src/lua/utils/errors.lua"))() ---@class Endpoint.Save.Args ---@field path string File path for the save file @@ -51,7 +50,7 @@ return { if not G.STAGE or G.STAGE ~= G.STAGES.RUN then send_response({ error = "Can only save during an active run", - error_code = errors.GAME_NOT_IN_RUN, + error_code = BB_ERRORS.GAME_NOT_IN_RUN, }) return end @@ -71,7 +70,7 @@ return { if not compressed_data then send_response({ error = "Failed to save game state", - error_code = errors.EXEC_INTERNAL_ERROR, + error_code = BB_ERRORS.EXEC_INTERNAL_ERROR, }) return end @@ -80,7 +79,7 @@ return { if not write_success then send_response({ error = "Failed to write save file to '" .. path .. "'", - error_code = errors.EXEC_INTERNAL_ERROR, + error_code = BB_ERRORS.EXEC_INTERNAL_ERROR, }) return end diff --git a/src/lua/endpoints/select.lua b/src/lua/endpoints/select.lua index a52c6da..db75238 100644 --- a/src/lua/endpoints/select.lua +++ b/src/lua/endpoints/select.lua @@ -1,5 +1,3 @@ -local gamestate = assert(SMODS.load_file("src/lua/utils/gamestate.lua"))() - ---@type Endpoint return { name = "select", @@ -30,7 +28,7 @@ return { local done = G.STATE == G.STATES.SELECTING_HAND and G.hand ~= nil if done then sendDebugMessage("select() completed", "BB.ENDPOINTS") - local state_data = gamestate.get_gamestate() + local state_data = BB_GAMESTATE.get_gamestate() send_response(state_data) end return done diff --git a/src/lua/endpoints/skip.lua b/src/lua/endpoints/skip.lua index ed317c9..cb018ba 100644 --- a/src/lua/endpoints/skip.lua +++ b/src/lua/endpoints/skip.lua @@ -3,9 +3,6 @@ -- -- Skip the current blind (Small or Big only, not Boss) -local gamestate = assert(SMODS.load_file("src/lua/utils/gamestate.lua"))() -local errors = assert(SMODS.load_file("src/lua/utils/errors.lua"))() - ---@type Endpoint return { name = "skip", @@ -22,14 +19,14 @@ return { 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 = gamestate.get_blinds_info()[current_blind_key] + 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({ error = "Cannot skip Boss blind", - error_code = errors.GAME_INVALID_STATE, + error_code = BB_ERRORS.GAME_INVALID_STATE, }) return end @@ -52,7 +49,7 @@ return { trigger = "condition", blocking = true, func = function() - local blinds = gamestate.get_blinds_info() + local blinds = BB_GAMESTATE.get_blinds_info() local done = ( G.STATE == G.STATES.BLIND_SELECT and G.GAME.blind_on_deck ~= nil @@ -61,7 +58,7 @@ return { ) if done then sendDebugMessage("Return skip()", "BB.ENDPOINTS") - local state_data = gamestate.get_gamestate() + local state_data = BB_GAMESTATE.get_gamestate() send_response(state_data) end diff --git a/src/lua/endpoints/start.lua b/src/lua/endpoints/start.lua index cc170d3..41b884e 100644 --- a/src/lua/endpoints/start.lua +++ b/src/lua/endpoints/start.lua @@ -3,9 +3,6 @@ -- -- Starts a new game run with specified deck and stake -local gamestate = assert(SMODS.load_file("src/lua/utils/gamestate.lua"))() -local errors = assert(SMODS.load_file("src/lua/utils/errors.lua"))() - -- Mapping tables for enum values local DECK_ENUM_TO_NAME = { RED = "Red Deck", @@ -79,7 +76,7 @@ return { send_response({ error = "Invalid stake enum. Must be one of: WHITE, RED, GREEN, BLACK, BLUE, PURPLE, ORANGE, GOLD. Got: " .. tostring(args.stake), - error_code = errors.SCHEMA_INVALID_VALUE, + error_code = BB_ERRORS.SCHEMA_INVALID_VALUE, }) return end @@ -91,7 +88,7 @@ return { send_response({ error = "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), - error_code = errors.SCHEMA_INVALID_VALUE, + error_code = BB_ERRORS.SCHEMA_INVALID_VALUE, }) return end @@ -118,7 +115,7 @@ return { sendDebugMessage("start() deck not found in game data: " .. deck_name, "BB.ENDPOINTS") send_response({ error = "Deck not found in game data: " .. deck_name, - error_code = errors.EXEC_INTERNAL_ERROR, + error_code = BB_ERRORS.EXEC_INTERNAL_ERROR, }) return end @@ -153,7 +150,7 @@ return { ) if done then sendDebugMessage("Return start()", "BB.ENDPOINTS") - local state_data = gamestate.get_gamestate() + local state_data = BB_GAMESTATE.get_gamestate() send_response(state_data) end From 75917ae43f28327846926238e3764d5b728bc292 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Thu, 20 Nov 2025 20:55:20 +0100 Subject: [PATCH 088/230] chore: define pytest dev marker --- pyproject.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index e6dc911..687f025 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,8 +38,8 @@ lint.task-tags = ["FIX", "TODO", "HACK", "WARN", "PERF", "NOTE", "TEST"] [tool.pyright] typeCheckingMode = "basic" -# [tool.pytest.ini_options] -# addopts = "--reruns 5" +[tool.pytest.ini_options] +markers = ["dev: marks tests that are currently developed"] [dependency-groups] dev = [ From 35771dcda6c836700cbb9ea9462f0e2809536dab Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Thu, 20 Nov 2025 20:55:45 +0100 Subject: [PATCH 089/230] chore(Makefile): add PYTEST_MARKER and restart balatro when generating fixtures --- Makefile | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 3407eba..1f822ad 100644 --- a/Makefile +++ b/Makefile @@ -8,6 +8,9 @@ BLUE := \033[34m RED := \033[31m RESET := \033[0m +# Test variables +PYTEST_MARKER ?= + help: ## Show this help message @echo "$(BLUE)BalatroBot Development Makefile$(RESET)" @echo "" @@ -40,6 +43,8 @@ quality: lint typecheck format ## Run all code quality checks @echo "$(GREEN)✓ All checks completed$(RESET)" fixtures: ## Generate fixtures + @echo "$(YELLOW)Starting Balatro...$(RESET)" + python balatro.py start --fast --debug @echo "$(YELLOW)Generating fixtures...$(RESET)" python tests/fixtures/generate.py @@ -47,7 +52,7 @@ test: ## Run tests head-less @echo "$(YELLOW)Starting Balatro...$(RESET)" python balatro.py start --fast --debug @echo "$(YELLOW)Running tests...$(RESET)" - pytest tests/lua + pytest tests/lua $(if $(PYTEST_MARKER),-m "$(PYTEST_MARKER)") -v all: lint format typecheck test ## Run all code quality checks and tests @echo "$(GREEN)✓ All checks completed$(RESET)" From b21058ad02769c189b4afc7ae0332f5d093fe09e Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Thu, 20 Nov 2025 22:49:47 +0100 Subject: [PATCH 090/230] fix: kill processes on a specific port in balatro.py --- balatro.py | 50 +++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 43 insertions(+), 7 deletions(-) diff --git a/balatro.py b/balatro.py index 270317a..7cbe5e9 100755 --- a/balatro.py +++ b/balatro.py @@ -15,8 +15,24 @@ LOGS_DIR = Path("logs") -def kill(): - """Kill all running Balatro instances.""" +def kill(port: int | None = None): + """Kill all running Balatro instances and optionally processes on a specific port.""" + if port: + print(f"Killing processes on port {port}...") + # Find processes listening on the 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} on port {port}") + subprocess.run(["kill", "-9", pid], stderr=subprocess.DEVNULL) + time.sleep(0.5) + print("Killing all Balatro instances...") subprocess.run(["pkill", "-f", "Balatro\\.app"], stderr=subprocess.DEVNULL) time.sleep(1) @@ -49,9 +65,9 @@ def status(): for pid in balatro_pids: result = subprocess.run( ["lsof", "-Pan", "-p", pid, "-i", "TCP"], - capture_output=True, - text=True, + stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, + text=True, ) port = None @@ -72,7 +88,22 @@ def status(): def start(args): """Start Balatro with given configuration.""" - # Kill existing instances first + # Kill processes on the specified port + print(f"Killing processes on port {args.port}...") + result = subprocess.run( + ["lsof", "-ti", f":{args.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} on port {args.port}") + subprocess.run(["kill", "-9", pid], stderr=subprocess.DEVNULL) + time.sleep(0.5) + + # Kill existing Balatro instances subprocess.run(["pkill", "-f", "Balatro\\.app"], stderr=subprocess.DEVNULL) time.sleep(1) @@ -176,10 +207,15 @@ def main(): ) # Kill command - subparsers.add_parser( + kill_parser = subparsers.add_parser( "kill", help="Kill all Balatro instances", ) + kill_parser.add_argument( + "--port", + type=int, + help="Also kill processes on this port", + ) # Status command subparsers.add_parser( @@ -191,7 +227,7 @@ def main(): # Execute command if args.command == "kill": - kill() + kill(args.port if hasattr(args, "port") else None) elif args.command == "status": status() elif args.command == "start": From 72636f4ff1286948ddedf59fe7e0d87bcc7bb599 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Thu, 20 Nov 2025 22:50:30 +0100 Subject: [PATCH 091/230] feat(lua.endpoints): add play endpoint --- balatrobot.lua | 2 + src/lua/endpoints/play.lua | 115 +++++++++++++++++++++++++++++++++++++ 2 files changed, 117 insertions(+) create mode 100644 src/lua/endpoints/play.lua diff --git a/balatrobot.lua b/balatrobot.lua index 8ee6369..fc2dfe7 100644 --- a/balatrobot.lua +++ b/balatrobot.lua @@ -19,6 +19,8 @@ BB_ENDPOINTS = { -- Blind selection endpoints "src/lua/endpoints/skip.lua", "src/lua/endpoints/select.lua", + -- Play/discard endpoints + "src/lua/endpoints/play.lua", -- If debug mode is enabled, debugger.lua will load test endpoints } diff --git a/src/lua/endpoints/play.lua b/src/lua/endpoints/play.lua new file mode 100644 index 0000000..c7e8fc8 --- /dev/null +++ b/src/lua/endpoints/play.lua @@ -0,0 +1,115 @@ +-- src/lua/endpoints/play.lua +-- Play Endpoint +-- +-- Play a card from the hand + +---@class Endpoint.Play.Args +---@field cards integer[] 0-based indices of cards to play + +---@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 Endpoint.Play.Args The arguments (cards) + ---@param send_response fun(response: table) Callback to send response + execute = function(args, send_response) + if #args.cards == 0 then + send_response({ + error = "Must provide at least one card to play", + error_code = BB_ERRORS.SCHEMA_INVALID_VALUE, + }) + return + end + + if #args.cards > G.hand.config.highlighted_limit then + send_response({ + error = "You can only play " .. G.hand.config.highlighted_limit .. " cards", + error_code = BB_ERRORS.SCHEMA_INVALID_VALUE, + }) + return + end + + for _, card_index in ipairs(args.cards) do + if not G.hand.cards[card_index + 1] then + send_response({ + error = "Invalid card index: " .. card_index, + error_code = BB_ERRORS.SCHEMA_INVALID_VALUE, + }) + 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({ + no_delete = true, + trigger = "immediate", + 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 + + -- NOTE: GAME_OVER is detected by gamestate.on_game_over callback in love.update + + if G.STATE == G.STATES.ROUND_EVAL then + local state_data = BB_GAMESTATE.get_gamestate() + send_response(state_data) + return true + end + + if draw_to_hand and hand_played and G.buttons and G.STATE == G.STATES.SELECTING_HAND then + local state_data = BB_GAMESTATE.get_gamestate() + send_response(state_data) + return true + end + + return false + end, + })) + end, +} From 3c5a82b71c97bc1ff56b360d37e1d03a958d5aac Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Thu, 20 Nov 2025 22:50:39 +0100 Subject: [PATCH 092/230] test(lua.endpoints): add tests for play endpoint --- tests/lua/endpoints/test_play.py | 116 +++++++++++++++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 tests/lua/endpoints/test_play.py diff --git a/tests/lua/endpoints/test_play.py b/tests/lua/endpoints/test_play.py new file mode 100644 index 0000000..20795f3 --- /dev/null +++ b/tests/lua/endpoints/test_play.py @@ -0,0 +1,116 @@ +"""Tests for src/lua/endpoints/play.lua""" + +import socket + +import pytest + +from tests.lua.conftest import api, assert_error_response, get_fixture_path + + +class TestPlayEndpoint: + """Test basic play endpoint functionality.""" + + def test_play_zero_cards(self, client: socket.socket) -> None: + """Test play endpoint from BLIND_SELECT state.""" + save = "state-SELECTING_HAND.jkr" + api(client, "load", {"path": str(get_fixture_path("play", save))}) + response = api(client, "play", {"cards": []}) + assert_error_response( + response, + expected_error_code="SCHEMA_INVALID_VALUE", + expected_message_contains="Must provide at least one card to play", + ) + + def test_play_six_cards(self, client: socket.socket) -> None: + """Test play endpoint from BLIND_SELECT state.""" + save = "state-SELECTING_HAND.jkr" + api(client, "load", {"path": str(get_fixture_path("play", save))}) + response = api(client, "play", {"cards": [0, 1, 2, 3, 4, 5]}) + assert_error_response( + response, + expected_error_code="SCHEMA_INVALID_VALUE", + expected_message_contains="You can only play 5 cards", + ) + + def test_play_out_of_range_cards(self, client: socket.socket) -> None: + """Test play endpoint from BLIND_SELECT state.""" + save = "state-SELECTING_HAND.jkr" + api(client, "load", {"path": str(get_fixture_path("play", save))}) + response = api(client, "play", {"cards": [999]}) + assert_error_response( + response, + expected_error_code="SCHEMA_INVALID_VALUE", + expected_message_contains="Invalid card index: 999", + ) + + def test_play_valid_cards_and_round_active(self, client: socket.socket) -> None: + """Test play endpoint from BLIND_SELECT state.""" + save = "state-SELECTING_HAND.jkr" + api(client, "load", {"path": str(get_fixture_path("play", save))}) + response = api(client, "play", {"cards": [0, 3, 4, 5, 6]}) + assert response["state"] == "SELECTING_HAND" + assert response["hands"]["Flush"]["played_this_round"] == 1 + assert response["round"]["chips"] == 260 + + def test_play_valid_cards_and_round_won(self, client: socket.socket) -> None: + """Test play endpoint from BLIND_SELECT state.""" + save = "state-SELECTING_HAND--round.chips-200.jkr" + api(client, "load", {"path": str(get_fixture_path("play", save))}) + response = api(client, "play", {"cards": [0, 3, 4, 5, 6]}) + assert response["state"] == "ROUND_EVAL" + + def test_play_valid_cards_and_game_won(self, client: socket.socket) -> None: + """Test play endpoint from BLIND_SELECT state.""" + save = "state-SELECTING_HAND--ante_num-8--blinds.boss.status-CURRENT--round.chips-1000000.jkr" + api(client, "load", {"path": str(get_fixture_path("play", save))}) + response = api(client, "play", {"cards": [0, 3, 4, 5, 6]}) + assert response["won"] is True + + def test_play_valid_cards_and_game_over(self, client: socket.socket) -> None: + """Test play endpoint from BLIND_SELECT state.""" + save = "state-SELECTING_HAND--round.hands_left-1.jkr" + api(client, "load", {"path": str(get_fixture_path("play", save))}) + # SMODS.calculate_context() in end_round() can take longer for game_over + response = api(client, "play", {"cards": [0]}, timeout=5) + assert response["state"] == "GAME_OVER" + + +class TestPlayEndpointValidation: + """Test play endpoint parameter validation.""" + + def test_missing_cards_parameter(self, client: socket.socket): + """Test that play fails when cards parameter is missing.""" + save = "state-SELECTING_HAND.jkr" + api(client, "load", {"path": str(get_fixture_path("play", save))}) + response = api(client, "play", {}) + assert_error_response( + response, + expected_error_code="SCHEMA_MISSING_REQUIRED", + expected_message_contains="Missing required field 'cards'", + ) + + def test_invalid_cards_type(self, client: socket.socket): + """Test that play fails when cards parameter is not an array.""" + save = "state-SELECTING_HAND.jkr" + api(client, "load", {"path": str(get_fixture_path("play", save))}) + response = api(client, "play", {"cards": "INVALID_CARDS"}) + assert_error_response( + response, + expected_error_code="SCHEMA_INVALID_TYPE", + expected_message_contains="Field 'cards' must be an array", + ) + + +class TestPlayEndpointStateRequirements: + """Test play endpoint state requirements.""" + + def test_play_from_BLIND_SELECT(self, client: socket.socket): + """Test that play fails when not in SELECTING_HAND state.""" + save = "state-BLIND_SELECT.jkr" + api(client, "load", {"path": str(get_fixture_path("play", save))}) + response = api(client, "play", {"cards": [0]}) + assert_error_response( + response, + expected_error_code="STATE_INVALID_STATE", + expected_message_contains="Endpoint 'play' requires one of these states:", + ) From 19a0a515d3096d6352b85e129dcfb3f61591d9dd Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Thu, 20 Nov 2025 23:04:24 +0100 Subject: [PATCH 093/230] feat(lua.utils): add gamestate.check_game_over() --- src/lua/utils/gamestate.lua | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/lua/utils/gamestate.lua b/src/lua/utils/gamestate.lua index 6624aa7..2164893 100644 --- a/src/lua/utils/gamestate.lua +++ b/src/lua/utils/gamestate.lua @@ -687,4 +687,23 @@ function gamestate.get_gamestate() 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 From c20dfb4335d7a31c17ac1e4d42f23c6ad799f46f Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Thu, 20 Nov 2025 23:07:06 +0100 Subject: [PATCH 094/230] chore: remove old editor configs --- .cursor/rules/docs-formatting.mdc | 51 --- .cursor/rules/python-development.mdc | 602 --------------------------- .vscode/extensions.json | 11 - .vscode/settings.json | 6 - 4 files changed, 670 deletions(-) delete mode 100644 .cursor/rules/docs-formatting.mdc delete mode 100644 .cursor/rules/python-development.mdc delete mode 100644 .vscode/extensions.json delete mode 100644 .vscode/settings.json 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/.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" -} From c0aeeb522c5b3142dd4ebfcfa1175ca7ce88af01 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Thu, 20 Nov 2025 23:33:21 +0100 Subject: [PATCH 095/230] feat(lua.endpoints): add cash out endpoint --- balatrobot.lua | 2 ++ src/lua/endpoints/cash_out.lua | 39 ++++++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+) create mode 100644 src/lua/endpoints/cash_out.lua diff --git a/balatrobot.lua b/balatrobot.lua index fc2dfe7..ce3269c 100644 --- a/balatrobot.lua +++ b/balatrobot.lua @@ -21,6 +21,8 @@ BB_ENDPOINTS = { "src/lua/endpoints/select.lua", -- Play/discard endpoints "src/lua/endpoints/play.lua", + -- Cash out endpoint + "src/lua/endpoints/cash_out.lua", -- If debug mode is enabled, debugger.lua will load test endpoints } diff --git a/src/lua/endpoints/cash_out.lua b/src/lua/endpoints/cash_out.lua new file mode 100644 index 0000000..2a60117 --- /dev/null +++ b/src/lua/endpoints/cash_out.lua @@ -0,0 +1,39 @@ +-- src/lua/endpoints/cash_out.lua +-- Cash Out Endpoint +-- +-- Cash out and collect round rewards + +---@type Endpoint +return { + name = "cash_out", + + description = "Cash out and collect round rewards", + + schema = {}, + + requires_state = { G.STATES.ROUND_EVAL }, + + ---@param _ table The arguments (none required) + ---@param send_response fun(response: table) Callback to send response + execute = function(_, send_response) + sendDebugMessage("Init cash_out()", "BB.ENDPOINTS") + G.FUNCS.cash_out({ config = {} }) + + G.E_MANAGER:add_event(Event({ + no_delete = true, + trigger = "condition", + blocking = false, + func = function() + local done = G.STATE == G.STATES.SHOP and G.shop and G.SHOP_SIGN and G.STATE_COMPLETE + + if done then + sendDebugMessage("Return cash_out()", "BB.ENDPOINTS") + local state_data = BB_GAMESTATE.get_gamestate() + send_response(state_data) + end + + return done + end, + })) + end, +} From 58e140a1f273f5d665a7ac90fd29ee6463b74865 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Thu, 20 Nov 2025 23:33:30 +0100 Subject: [PATCH 096/230] test(lua.endpoints): add test for cash_out endpoint --- tests/lua/endpoints/test_cash_out.py | 44 ++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 tests/lua/endpoints/test_cash_out.py diff --git a/tests/lua/endpoints/test_cash_out.py b/tests/lua/endpoints/test_cash_out.py new file mode 100644 index 0000000..2017ed7 --- /dev/null +++ b/tests/lua/endpoints/test_cash_out.py @@ -0,0 +1,44 @@ +"""Tests for src/lua/endpoints/cash_out.lua""" + +import socket +from typing import Any + +from tests.lua.conftest import api, assert_error_response, get_fixture_path + + +def verify_cash_out_response(response: dict[str, Any]) -> None: + """Verify that cash_out response has expected fields.""" + # Verify state field - should transition to SHOP after cashing out + assert "state" in response + assert isinstance(response["state"], str) + assert response["state"] == "SHOP" + + # Verify shop field exists + assert "shop" in response + assert isinstance(response["shop"], dict) + + +class TestCashOutEndpoint: + """Test basic cash_out endpoint functionality.""" + + def test_cash_out_from_round_eval(self, client: socket.socket) -> None: + """Test cashing out from ROUND_EVAL state.""" + save = "state-ROUND_EVAL.jkr" + api(client, "load", {"path": str(get_fixture_path("cash_out", save))}) + response = api(client, "cash_out", {}) + verify_cash_out_response(response) + assert response["state"] == "SHOP" + + +class TestCashOutEndpointStateRequirements: + """Test cash_out endpoint state requirements.""" + + def test_cash_out_from_MENU(self, client: socket.socket): + """Test that cash_out fails when not in ROUND_EVAL state.""" + response = api(client, "menu", {}) + response = api(client, "cash_out", {}) + assert_error_response( + response, + expected_error_code="STATE_INVALID_STATE", + expected_message_contains="Endpoint 'cash_out' requires one of these states:", + ) From 3919fa366f0023ad8bcfd3bc85bbe7861483bc6c Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Thu, 20 Nov 2025 23:34:06 +0100 Subject: [PATCH 097/230] fix: add GAME_OVER check to love.update --- balatrobot.lua | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/balatrobot.lua b/balatrobot.lua index ce3269c..3543e3b 100644 --- a/balatrobot.lua +++ b/balatrobot.lua @@ -51,9 +51,11 @@ if not dispatcher_ok then return end --- Hook into love.update to run server update loop +-- 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 From eb1c9248bfa2abe650343109b48050d87d32064a Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Mon, 24 Nov 2025 17:01:11 +0100 Subject: [PATCH 098/230] fix(lua.endpoint): properly wait for SHOP state --- src/lua/endpoints/cash_out.lua | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/src/lua/endpoints/cash_out.lua b/src/lua/endpoints/cash_out.lua index 2a60117..cf43372 100644 --- a/src/lua/endpoints/cash_out.lua +++ b/src/lua/endpoints/cash_out.lua @@ -19,19 +19,23 @@ return { sendDebugMessage("Init cash_out()", "BB.ENDPOINTS") G.FUNCS.cash_out({ config = {} }) + -- Wait for SHOP state after state transition completes G.E_MANAGER:add_event(Event({ - no_delete = true, trigger = "condition", blocking = false, func = function() - local done = G.STATE == G.STATES.SHOP and G.shop and G.SHOP_SIGN and G.STATE_COMPLETE - - if done then - sendDebugMessage("Return cash_out()", "BB.ENDPOINTS") - local state_data = BB_GAMESTATE.get_gamestate() - send_response(state_data) + local done = false + if G.STATE == G.STATES.SHOP and G.STATE_COMPLETE then + local done_vouchers = G.shop_vouchers and G.shop_vouchers.cards and #G.shop_vouchers.cards > 0 + local done_packs = G.shop_booster and G.shop_booster.cards and #G.shop_booster.cards > 0 + local done_jokers = G.shop_jokers and G.shop_jokers.cards and #G.shop_jokers.cards > 0 + done = done_vouchers or done_packs or done_jokers + 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, })) From 19f3a211547fef9a66700f9bf65a0e7bf2f2bf8c Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Mon, 24 Nov 2025 17:01:48 +0100 Subject: [PATCH 099/230] fix(lua.endpoints): properly wait for ROUND_EVAL and SHOP states in load --- src/lua/endpoints/load.lua | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/lua/endpoints/load.lua b/src/lua/endpoints/load.lua index 01653c8..c204570 100644 --- a/src/lua/endpoints/load.lua +++ b/src/lua/endpoints/load.lua @@ -99,6 +99,21 @@ return { 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 and G.STATE_COMPLETE then + local done_vouchers = G.shop_vouchers and G.shop_vouchers.cards and #G.shop_vouchers.cards > 0 + local done_packs = G.shop_booster and G.shop_booster.cards and #G.shop_booster.cards > 0 + local done_jokers = G.shop_jokers and G.shop_jokers.cards and #G.shop_jokers.cards > 0 + done = done_vouchers or done_packs or done_jokers + end + --- TODO: add other states here ... if done then From f2184e7bffb0fb837d25bc47fe8faeef9a7aa43f Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Mon, 24 Nov 2025 17:02:36 +0100 Subject: [PATCH 100/230] fix(lua.endpoints): properly wait for ROUND_EVAL state --- src/lua/endpoints/play.lua | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/src/lua/endpoints/play.lua b/src/lua/endpoints/play.lua index c7e8fc8..e5b4f2c 100644 --- a/src/lua/endpoints/play.lua +++ b/src/lua/endpoints/play.lua @@ -96,10 +96,21 @@ return { -- NOTE: GAME_OVER is detected by gamestate.on_game_over callback in love.update - if G.STATE == G.STATES.ROUND_EVAL then - local state_data = BB_GAMESTATE.get_gamestate() - send_response(state_data) - return true + if G.STATE == G.STATES.ROUND_EVAL and G.round_eval then + -- Go to the cash out stage + for _, b in ipairs(G.I.UIBOX) do + if b:get_UIE_by_ID("cash_out_button") then + local state_data = BB_GAMESTATE.get_gamestate() + send_response(state_data) + return true + end + end + -- Game is won + if G.GAME.won then + local state_data = BB_GAMESTATE.get_gamestate() + 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 From 10934b4b093c1b94b66413cd28300fb2ff787f9e Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Mon, 24 Nov 2025 17:03:12 +0100 Subject: [PATCH 101/230] chore(lua.utils): update TODOs --- src/lua/utils/errors.lua | 1 - src/lua/utils/types.lua | 2 ++ 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/lua/utils/errors.lua b/src/lua/utils/errors.lua index 647e1cf..2b06618 100644 --- a/src/lua/utils/errors.lua +++ b/src/lua/utils/errors.lua @@ -59,7 +59,6 @@ return { EXEC_FILE_WRITE_ERROR = "EXEC_FILE_WRITE_ERROR", -- Failed to write file EXEC_INVALID_SAVE_FORMAT = "EXEC_INVALID_SAVE_FORMAT", -- Invalid save file format - -- TODO: Define future error codes as needed: -- -- Here are some examples of future error codes: -- PROTO_INCOMPLETE - No newline terminator diff --git a/src/lua/utils/types.lua b/src/lua/utils/types.lua index a18644c..604e269 100644 --- a/src/lua/utils/types.lua +++ b/src/lua/utils/types.lua @@ -34,6 +34,8 @@ ---@field vouchers Area? Vouchers area (available during shop phase) ---@field won boolean? Whether the game has been won +--- TODO: add packs to GameState + ---@alias Deck ---| "RED" # +1 discard every round ---| "BLUE" # +1 hand every round From 2a2ecc4fc1b43fabbee50f690d2d65801f958503 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Mon, 24 Nov 2025 17:03:46 +0100 Subject: [PATCH 102/230] chore: formatting balatro.py --- balatro.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/balatro.py b/balatro.py index 7cbe5e9..a02a615 100755 --- a/balatro.py +++ b/balatro.py @@ -32,7 +32,7 @@ def kill(port: int | None = None): print(f" Killing PID {pid} on port {port}") subprocess.run(["kill", "-9", pid], stderr=subprocess.DEVNULL) time.sleep(0.5) - + print("Killing all Balatro instances...") subprocess.run(["pkill", "-f", "Balatro\\.app"], stderr=subprocess.DEVNULL) time.sleep(1) @@ -102,7 +102,7 @@ def start(args): print(f" Killing PID {pid} on port {args.port}") subprocess.run(["kill", "-9", pid], stderr=subprocess.DEVNULL) time.sleep(0.5) - + # Kill existing Balatro instances subprocess.run(["pkill", "-f", "Balatro\\.app"], stderr=subprocess.DEVNULL) time.sleep(1) From 41c1dcfa7c2f0d1b1c58bf6bca0945c3ff271bb0 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Mon, 24 Nov 2025 17:04:26 +0100 Subject: [PATCH 103/230] test(fixtures): add --overwrite flag and add fixtures for new tests --- tests/fixtures/generate.py | 112 +++++++++++++++++++++++++++++++++++-- 1 file changed, 108 insertions(+), 4 deletions(-) diff --git a/tests/fixtures/generate.py b/tests/fixtures/generate.py index 3920ffa..367659e 100644 --- a/tests/fixtures/generate.py +++ b/tests/fixtures/generate.py @@ -6,12 +6,14 @@ Usage: python generate.py + python generate.py --overwrite # Regenerate all fixtures Requirements: - Balatro must be running with the BalatroBot mod loaded - Default connection: 127.0.0.1:12346 """ +import argparse import json import socket from dataclasses import dataclass @@ -104,6 +106,7 @@ def build_fixtures() -> list[FixtureSpec]: FIXTURES_DIR / "menu" / "state-BLIND_SELECT.jkr", FIXTURES_DIR / "health" / "state-BLIND_SELECT.jkr", FIXTURES_DIR / "start" / "state-BLIND_SELECT.jkr", + FIXTURES_DIR / "play" / "state-BLIND_SELECT.jkr", FIXTURES_DIR / "skip" / "state-BLIND_SELECT--blinds.small.status-SELECT.jkr", @@ -150,24 +153,125 @@ def build_fixtures() -> list[FixtureSpec]: ("skip", {}), ], ), + FixtureSpec( + paths=[ + FIXTURES_DIR / "play" / "state-SELECTING_HAND.jkr", + FIXTURES_DIR / "discard" / "state-SELECTING_HAND.jkr", + FIXTURES_DIR / "set" / "state-SELECTING_HAND.jkr", + ], + setup=[ + ("menu", {}), + ("start", {"deck": "RED", "stake": "WHITE", "seed": "TEST123"}), + ("select", {}), + ], + ), + FixtureSpec( + paths=[ + FIXTURES_DIR / "play" / "state-SELECTING_HAND--round.chips-200.jkr", + ], + setup=[ + ("menu", {}), + ("start", {"deck": "RED", "stake": "WHITE", "seed": "TEST123"}), + ("select", {}), + ("set", {"chips": 200}), + ], + ), + FixtureSpec( + paths=[ + FIXTURES_DIR / "play" / "state-SELECTING_HAND--round.hands_left-1.jkr", + ], + setup=[ + ("menu", {}), + ("start", {"deck": "RED", "stake": "WHITE", "seed": "TEST123"}), + ("select", {}), + ("set", {"hands": 1}), + ], + ), + FixtureSpec( + paths=[ + FIXTURES_DIR + / "play" + / "state-SELECTING_HAND--ante_num-8--blinds.boss.status-CURRENT--round.chips-1000000.jkr", + ], + setup=[ + ("menu", {}), + ("start", {"deck": "RED", "stake": "WHITE", "seed": "TEST123"}), + ("skip", {}), + ("skip", {}), + ("select", {}), + ("set", {"ante": 8}), + ("set", {"chips": 1_000_000}), + ], + ), + FixtureSpec( + paths=[ + FIXTURES_DIR / "cash_out" / "state-ROUND_EVAL.jkr", + ], + setup=[ + ("menu", {}), + ("start", {"deck": "RED", "stake": "WHITE", "seed": "TEST123"}), + ("select", {}), + ("set", {"chips": 1000}), + ("play", {"cards": [0]}), + ], + ), + FixtureSpec( + paths=[ + FIXTURES_DIR / "set" / "state-SHOP.jkr", + ], + setup=[ + ("menu", {}), + ("start", {"deck": "RED", "stake": "WHITE", "seed": "TEST123"}), + ("select", {}), + ("set", {"chips": 1000}), + ("play", {"cards": [0]}), + ("cash_out", {}), + ], + ), ] -def should_generate(spec: FixtureSpec) -> bool: - """Check if fixture should be generated (any path missing).""" +def should_generate(spec: FixtureSpec, overwrite: bool = False) -> bool: + """Check if fixture should be generated. + + Args: + spec: Fixture specification to check. + overwrite: If True, generate regardless of existing files. + + Returns: + True if fixture should be generated. + """ + if overwrite: + return True return not all(path.exists() for path in spec.paths) def main() -> int: """Main entry point.""" + parser = argparse.ArgumentParser( + description="Generate test fixture files for endpoint testing." + ) + parser.add_argument( + "-o", + "--overwrite", + action="store_true", + help="Regenerate all fixtures, overwriting existing files", + ) + args = parser.parse_args() + print("BalatroBot Fixture Generator") - print(f"Connecting to {HOST}:{PORT}\n") + print(f"Connecting to {HOST}:{PORT}") + if args.overwrite: + print("Mode: Overwrite all fixtures\n") + else: + print("Mode: Generate missing fixtures only\n") fixtures = build_fixtures() try: with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: sock.connect((HOST, PORT)) + sock.settimeout(10) success = 0 skipped = 0 @@ -177,7 +281,7 @@ def main() -> int: total=len(fixtures), desc="Generating fixtures", unit="fixture" ) as pbar: for spec in fixtures: - if should_generate(spec): + if should_generate(spec, overwrite=args.overwrite): if generate_fixture(sock, spec, pbar): success += 1 else: From 8084d6371c109f6f7266649207f4a572ce7cb3be Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Mon, 24 Nov 2025 17:05:42 +0100 Subject: [PATCH 104/230] feat(lua.endpoints): add set endpoint --- balatrobot.lua | 1 + src/lua/endpoints/set.lua | 209 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 210 insertions(+) create mode 100644 src/lua/endpoints/set.lua diff --git a/balatrobot.lua b/balatrobot.lua index 3543e3b..90c375b 100644 --- a/balatrobot.lua +++ b/balatrobot.lua @@ -13,6 +13,7 @@ BB_ENDPOINTS = { -- Save/load endpoints "src/lua/endpoints/save.lua", "src/lua/endpoints/load.lua", + "src/lua/endpoints/set.lua", -- Gameplay endpoints "src/lua/endpoints/menu.lua", "src/lua/endpoints/start.lua", diff --git a/src/lua/endpoints/set.lua b/src/lua/endpoints/set.lua new file mode 100644 index 0000000..9aa27f1 --- /dev/null +++ b/src/lua/endpoints/set.lua @@ -0,0 +1,209 @@ +-- Set a in-game value + +---@class Endpoint.Set.Args +---@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 + +---@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 Endpoint.Set.Args The arguments + ---@param send_response fun(response: table) Callback to send response + 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({ + error = "Can only set during an active run", + error_code = BB_ERRORS.GAME_NOT_IN_RUN, + }) + 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({ + error = "Must provide at least one field to set", + error_code = BB_ERRORS.SCHEMA_INVALID_VALUE, + }) + return + end + + -- Set money + if args.money then + if args.money < 0 then + send_response({ + error = "Money must be a positive integer", + error_code = BB_ERRORS.SCHEMA_INVALID_VALUE, + }) + 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({ + error = "Chips must be a positive integer", + error_code = BB_ERRORS.SCHEMA_INVALID_VALUE, + }) + 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({ + error = "Ante must be a positive integer", + error_code = BB_ERRORS.SCHEMA_INVALID_VALUE, + }) + 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({ + error = "Round must be a positive integer", + error_code = BB_ERRORS.SCHEMA_INVALID_VALUE, + }) + 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({ + error = "Hands must be a positive integer", + error_code = BB_ERRORS.SCHEMA_INVALID_VALUE, + }) + 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({ + error = "Discards must be a positive integer", + error_code = BB_ERRORS.SCHEMA_INVALID_VALUE, + }) + 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({ + error = "Can re-stock shop only in SHOP state", + error_code = BB_ERRORS.GAME_INVALID_STATE, + }) + 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.cards and #G.shop_vouchers.cards > 0 + local done_packs = G.shop_booster and G.shop_booster.cards and #G.shop_booster.cards > 0 + local done_jokers = G.shop_jokers and G.shop_jokers.cards and #G.shop_jokers.cards > 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, +} From bcddf1fd4ee05097643dd32ee49ebf0e4f78bb3e Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Mon, 24 Nov 2025 17:06:16 +0100 Subject: [PATCH 105/230] test(lua.endpoints): add tests for set endpoint --- tests/lua/endpoints/test_set.py | 242 ++++++++++++++++++++++++++++++++ 1 file changed, 242 insertions(+) create mode 100644 tests/lua/endpoints/test_set.py diff --git a/tests/lua/endpoints/test_set.py b/tests/lua/endpoints/test_set.py new file mode 100644 index 0000000..9ab6e9b --- /dev/null +++ b/tests/lua/endpoints/test_set.py @@ -0,0 +1,242 @@ +"""Tests for src/lua/endpoints/set.lua""" + +import socket + +from tests.lua.conftest import api, assert_error_response, get_fixture_path + + +class TestSetEndpoint: + """Test basic set endpoint functionality.""" + + def test_set_game_not_in_run(self, client: socket.socket) -> None: + """Test that set fails when game is not in run.""" + api(client, "menu", {}) + response = api(client, "set", {}) + assert_error_response( + response, + expected_error_code="GAME_NOT_IN_RUN", + expected_message_contains="Can only set during an active run", + ) + + def test_set_no_fields(self, client: socket.socket) -> None: + """Test that set fails when no fields are provided.""" + save = "state-SELECTING_HAND.jkr" + api(client, "load", {"path": str(get_fixture_path("set", save))}) + response = api(client, "set", {}) + assert_error_response( + response, + expected_error_code="SCHEMA_INVALID_VALUE", + expected_message_contains="Must provide at least one field to set", + ) + + def test_set_negative_money(self, client: socket.socket) -> None: + """Test that set fails when money is negative.""" + save = "state-SELECTING_HAND.jkr" + api(client, "load", {"path": str(get_fixture_path("set", save))}) + response = api(client, "set", {"money": -100}) + assert_error_response( + response, + expected_error_code="SCHEMA_INVALID_VALUE", + expected_message_contains="Money must be a positive integer", + ) + + def test_set_money(self, client: socket.socket) -> None: + """Test that set succeeds when money is positive.""" + save = "state-SELECTING_HAND.jkr" + api(client, "load", {"path": str(get_fixture_path("set", save))}) + response = api(client, "set", {"money": 100}) + assert response["money"] == 100 + + def test_set_negative_chips(self, client: socket.socket) -> None: + """Test that set fails when chips is negative.""" + save = "state-SELECTING_HAND.jkr" + api(client, "load", {"path": str(get_fixture_path("set", save))}) + response = api(client, "set", {"chips": -100}) + assert_error_response( + response, + expected_error_code="SCHEMA_INVALID_VALUE", + expected_message_contains="Chips must be a positive integer", + ) + + def test_set_chips(self, client: socket.socket) -> None: + """Test that set succeeds when chips is positive.""" + save = "state-SELECTING_HAND.jkr" + api(client, "load", {"path": str(get_fixture_path("set", save))}) + response = api(client, "set", {"chips": 100}) + assert response["round"]["chips"] == 100 + + def test_set_negative_ante(self, client: socket.socket) -> None: + """Test that set fails when ante is negative.""" + save = "state-SELECTING_HAND.jkr" + api(client, "load", {"path": str(get_fixture_path("set", save))}) + response = api(client, "set", {"ante": -8}) + assert_error_response( + response, + expected_error_code="SCHEMA_INVALID_VALUE", + expected_message_contains="Ante must be a positive integer", + ) + + def test_set_ante(self, client: socket.socket) -> None: + """Test that set succeeds when ante is positive.""" + save = "state-SELECTING_HAND.jkr" + api(client, "load", {"path": str(get_fixture_path("set", save))}) + response = api(client, "set", {"ante": 8}) + assert response["ante_num"] == 8 + + def test_set_negative_round(self, client: socket.socket) -> None: + """Test that set fails when round is negative.""" + save = "state-SELECTING_HAND.jkr" + api(client, "load", {"path": str(get_fixture_path("set", save))}) + response = api(client, "set", {"round": -5}) + assert_error_response( + response, + expected_error_code="SCHEMA_INVALID_VALUE", + expected_message_contains="Round must be a positive integer", + ) + + def test_set_round(self, client: socket.socket) -> None: + """Test that set succeeds when round is positive.""" + save = "state-SELECTING_HAND.jkr" + api(client, "load", {"path": str(get_fixture_path("set", save))}) + response = api(client, "set", {"round": 5}) + assert response["round_num"] == 5 + + def test_set_negative_hands(self, client: socket.socket) -> None: + """Test that set fails when hands is negative.""" + save = "state-SELECTING_HAND.jkr" + api(client, "load", {"path": str(get_fixture_path("set", save))}) + response = api(client, "set", {"hands": -10}) + assert_error_response( + response, + expected_error_code="SCHEMA_INVALID_VALUE", + expected_message_contains="Hands must be a positive integer", + ) + + def test_set_hands(self, client: socket.socket) -> None: + """Test that set succeeds when hands is positive.""" + save = "state-SELECTING_HAND.jkr" + api(client, "load", {"path": str(get_fixture_path("set", save))}) + response = api(client, "set", {"hands": 10}) + assert response["round"]["hands_left"] == 10 + + def test_set_negative_discards(self, client: socket.socket) -> None: + """Test that set fails when discards is negative.""" + save = "state-SELECTING_HAND.jkr" + api(client, "load", {"path": str(get_fixture_path("set", save))}) + response = api(client, "set", {"discards": -10}) + assert_error_response( + response, + expected_error_code="SCHEMA_INVALID_VALUE", + expected_message_contains="Discards must be a positive integer", + ) + + def test_set_discards(self, client: socket.socket) -> None: + """Test that set succeeds when discards is positive.""" + save = "state-SELECTING_HAND.jkr" + api(client, "load", {"path": str(get_fixture_path("set", save))}) + response = api(client, "set", {"discards": 10}) + assert response["round"]["discards_left"] == 10 + + def test_set_shop_from_selecting_hand(self, client: socket.socket) -> None: + """Test that set fails when shop is called from SELECTING_HAND state.""" + save = "state-SELECTING_HAND.jkr" + api(client, "load", {"path": str(get_fixture_path("set", save))}) + response = api(client, "set", {"shop": True}) + assert_error_response( + response, + expected_error_code="GAME_INVALID_STATE", + expected_message_contains="Can re-stock shop only in SHOP state", + ) + + def test_set_shop_from_SHOP(self, client: socket.socket) -> None: + """Test that set fails when shop is called from SHOP state.""" + save = "state-SHOP.jkr" + response = api(client, "load", {"path": str(get_fixture_path("set", save))}) + assert "error" not in response + before = api(client, "gamestate", {}) + after = api(client, "set", {"shop": True}) + assert len(after["shop"]["cards"]) > 0 + assert len(before["shop"]["cards"]) > 0 + assert after["shop"] != before["shop"] + assert after["vouchers"] != before["vouchers"] + + +class TestSetEndpointValidation: + """Test set endpoint parameter validation.""" + + def test_invalid_money_type(self, client: socket.socket): + """Test that set fails when money parameter is not an integer.""" + save = "state-SELECTING_HAND.jkr" + api(client, "load", {"path": str(get_fixture_path("set", save))}) + response = api(client, "set", {"money": "INVALID_STRING"}) + assert_error_response( + response, + expected_error_code="SCHEMA_INVALID_TYPE", + expected_message_contains="Field 'money' must be an integer", + ) + + def test_invalid_chips_type(self, client: socket.socket): + """Test that set fails when chips parameter is not an integer.""" + save = "state-SELECTING_HAND.jkr" + api(client, "load", {"path": str(get_fixture_path("set", save))}) + response = api(client, "set", {"chips": "INVALID_STRING"}) + assert_error_response( + response, + expected_error_code="SCHEMA_INVALID_TYPE", + expected_message_contains="Field 'chips' must be an integer", + ) + + def test_invalid_ante_type(self, client: socket.socket): + """Test that set fails when ante parameter is not an integer.""" + save = "state-SELECTING_HAND.jkr" + api(client, "load", {"path": str(get_fixture_path("set", save))}) + response = api(client, "set", {"ante": "INVALID_STRING"}) + assert_error_response( + response, + expected_error_code="SCHEMA_INVALID_TYPE", + expected_message_contains="Field 'ante' must be an integer", + ) + + def test_invalid_round_type(self, client: socket.socket): + """Test that set fails when round parameter is not an integer.""" + save = "state-SELECTING_HAND.jkr" + api(client, "load", {"path": str(get_fixture_path("set", save))}) + response = api(client, "set", {"round": "INVALID_STRING"}) + assert_error_response( + response, + expected_error_code="SCHEMA_INVALID_TYPE", + expected_message_contains="Field 'round' must be an integer", + ) + + def test_invalid_hands_type(self, client: socket.socket): + """Test that set fails when hands parameter is not an integer.""" + save = "state-SELECTING_HAND.jkr" + api(client, "load", {"path": str(get_fixture_path("set", save))}) + response = api(client, "set", {"hands": "INVALID_STRING"}) + assert_error_response( + response, + expected_error_code="SCHEMA_INVALID_TYPE", + expected_message_contains="Field 'hands' must be an integer", + ) + + def test_invalid_discards_type(self, client: socket.socket): + """Test that set fails when discards parameter is not an integer.""" + save = "state-SELECTING_HAND.jkr" + api(client, "load", {"path": str(get_fixture_path("set", save))}) + response = api(client, "set", {"discards": "INVALID_STRING"}) + assert_error_response( + response, + expected_error_code="SCHEMA_INVALID_TYPE", + expected_message_contains="Field 'discards' must be an integer", + ) + + def test_invalid_shop_type(self, client: socket.socket): + """Test that set fails when shop parameter is not a boolean.""" + save = "state-SHOP.jkr" + api(client, "load", {"path": str(get_fixture_path("set", save))}) + response = api(client, "set", {"shop": "INVALID_STRING"}) + assert_error_response( + response, + expected_error_code="SCHEMA_INVALID_TYPE", + expected_message_contains="Field 'shop' must be of type boolean", + ) From 9a7a8d31bce5039140afe356e77757110dc11b95 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Mon, 24 Nov 2025 17:19:25 +0100 Subject: [PATCH 106/230] feat(lua.utils): add booster packs to gamestate --- src/lua/utils/gamestate.lua | 6 ++++++ src/lua/utils/types.lua | 5 ++--- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/lua/utils/gamestate.lua b/src/lua/utils/gamestate.lua index 2164893..0da654f 100644 --- a/src/lua/utils/gamestate.lua +++ b/src/lua/utils/gamestate.lua @@ -239,6 +239,8 @@ local function extract_card(card) set = "planet" elseif ability_set == "Spectral" then set = "spectral" + elseif ability_set == "Booster" then + set = "booster" elseif card.ability.effect and card.ability.effect ~= "Base" then set = "enhanced" end @@ -684,6 +686,10 @@ function gamestate.get_gamestate() state_data.vouchers = extract_area(G.shop_vouchers) end + if G.shop_booster then + state_data.packs = extract_area(G.shop_booster) + end + return state_data end diff --git a/src/lua/utils/types.lua b/src/lua/utils/types.lua index 604e269..7cc17f6 100644 --- a/src/lua/utils/types.lua +++ b/src/lua/utils/types.lua @@ -32,10 +32,9 @@ ---@field hand Area? Hand area (available during playing 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 ---- TODO: add packs to GameState - ---@alias Deck ---| "RED" # +1 discard every round ---| "BLUE" # +1 hand every round @@ -120,7 +119,7 @@ ---@class Card ---@field id integer Unique identifier for the card (sort_id) ----@field set "default" | "joker" | "tarot" | "planet" | "spectral" | "enhanced" Card set/type +---@field set "default" | "joker" | "tarot" | "planet" | "spectral" | "enhanced" | "booster" 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) From aca15decc57e53b0c7525c3efde0571f5012fe1b Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Mon, 24 Nov 2025 17:22:18 +0100 Subject: [PATCH 107/230] refactor(lua.utils): convert all enums to uppercase --- src/lua/utils/gamestate.lua | 20 ++++++++++---------- src/lua/utils/types.lua | 8 ++++---- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/src/lua/utils/gamestate.lua b/src/lua/utils/gamestate.lua index 0da654f..2df1841 100644 --- a/src/lua/utils/gamestate.lua +++ b/src/lua/utils/gamestate.lua @@ -132,17 +132,17 @@ local function extract_card_modifier(card) -- Seal (direct property) if card.seal then - modifier.seal = card.seal + modifier.seal = string.upper(card.seal) end -- Edition (table with type/key) if card.edition and card.edition.type then - modifier.edition = card.edition.type + 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 = card.ability.effect + modifier.enhancement = string.upper(card.ability.effect) end -- Eternal (boolean from ability) @@ -228,21 +228,21 @@ end ---@return Card card The Card object local function extract_card(card) -- Determine set - local set = "default" + local set = "DEFAULT" if card.ability and card.ability.set then local ability_set = card.ability.set if ability_set == "Joker" then - set = "joker" + set = "JOKER" elseif ability_set == "Tarot" then - set = "tarot" + set = "TAROT" elseif ability_set == "Planet" then - set = "planet" + set = "PLANET" elseif ability_set == "Spectral" then - set = "spectral" + set = "SPECTRAL" elseif ability_set == "Booster" then - set = "booster" + set = "BOOSTER" elseif card.ability.effect and card.ability.effect ~= "Base" then - set = "enhanced" + set = "ENHANCED" end end diff --git a/src/lua/utils/types.lua b/src/lua/utils/types.lua index 7cc17f6..38e4161 100644 --- a/src/lua/utils/types.lua +++ b/src/lua/utils/types.lua @@ -119,7 +119,7 @@ ---@class Card ---@field id integer Unique identifier for the card (sort_id) ----@field set "default" | "joker" | "tarot" | "planet" | "spectral" | "enhanced" | "booster" Card set/type +---@field set "DEFAULT" | "JOKER" | "TAROT" | "PLANET" | "SPECTRAL" | "ENHANCED" | "BOOSTER" 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) @@ -132,9 +132,9 @@ ---@field effect string Description of the card's effect (from UI) ---@class Card.Modifier ----@field seal "red" | "blue" | "gold" | "purple"? Seal type ----@field edition "holo" | "foil" | "polychrome" | "negative"? Edition type ----@field enhancement "bonus" | "mult" | "wild" | "glass" | "steel" | "stone" | "gold" | "lucky"? Enhancement type +---@field seal "RED" | "BLUE" | "GOLD" | "PURPLE"? Seal type +---@field edition "HOLO" | "FOIL" | "POLYCHROME" | "NEGATIVE"? Edition type +---@field enhancement "BONUS" | "MULT" | "WILD" | "GLASS" | "STEEL" | "STONE" | "GOLD" | "LUCKY"? Enhancement type ---@field eternal boolean? If true, card cannot be sold or destroyed ---@field perishable integer? Number of rounds remaining (only if > 0) ---@field rental boolean? If true, card costs money at end of round From ba36e488f0feedfa5d40b2547b9d86a54dad341c Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Mon, 24 Nov 2025 17:33:59 +0100 Subject: [PATCH 108/230] test(lua.endpoints): add multi-set test for set endpoint --- tests/lua/endpoints/test_set.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/tests/lua/endpoints/test_set.py b/tests/lua/endpoints/test_set.py index 9ab6e9b..ed31b0e 100644 --- a/tests/lua/endpoints/test_set.py +++ b/tests/lua/endpoints/test_set.py @@ -158,7 +158,21 @@ def test_set_shop_from_SHOP(self, client: socket.socket) -> None: assert len(after["shop"]["cards"]) > 0 assert len(before["shop"]["cards"]) > 0 assert after["shop"] != before["shop"] - assert after["vouchers"] != before["vouchers"] + 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: socket.socket) -> None: + """Test that set fails when shop is called from SHOP state.""" + save = "state-SHOP.jkr" + response = api(client, "load", {"path": str(get_fixture_path("set", save))}) + assert "error" not in response + before = api(client, "gamestate", {}) + after = api(client, "set", {"shop": True, "round": 5, "money": 100}) + assert after["shop"] != before["shop"] + assert after["packs"] != before["packs"] + assert after["vouchers"] != before["vouchers"] # here only the id is changed + assert after["round_num"] == 5 + assert after["money"] == 100 class TestSetEndpointValidation: From 7e25460d3540038a82a5acfc4e090f2ca581cfed Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Mon, 24 Nov 2025 18:18:19 +0100 Subject: [PATCH 109/230] feat(lua.endpoints): add discard endpoint --- balatrobot.lua | 1 + src/lua/endpoints/discard.lua | 99 +++++++++++++++++++++++++++++++++++ 2 files changed, 100 insertions(+) create mode 100644 src/lua/endpoints/discard.lua diff --git a/balatrobot.lua b/balatrobot.lua index 90c375b..c316fb1 100644 --- a/balatrobot.lua +++ b/balatrobot.lua @@ -22,6 +22,7 @@ BB_ENDPOINTS = { "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", -- If debug mode is enabled, debugger.lua will load test endpoints diff --git a/src/lua/endpoints/discard.lua b/src/lua/endpoints/discard.lua new file mode 100644 index 0000000..5edb4e4 --- /dev/null +++ b/src/lua/endpoints/discard.lua @@ -0,0 +1,99 @@ +-- src/lua/endpoints/discard.lua +-- Discard Endpoint +-- +-- Discard cards from the hand + +---@class Endpoint.Discard.Args +---@field cards integer[] 0-based indices of cards to discard + +---@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 Endpoint.Discard.Args The arguments (cards) + ---@param send_response fun(response: table) Callback to send response + execute = function(args, send_response) + if #args.cards == 0 then + send_response({ + error = "Must provide at least one card to discard", + error_code = BB_ERRORS.SCHEMA_INVALID_VALUE, + }) + return + end + + if G.GAME.current_round.discards_left <= 0 then + send_response({ + error = "No discards left", + error_code = BB_ERRORS.SCHEMA_INVALID_VALUE, + }) + return + end + + if #args.cards > G.hand.config.highlighted_limit then + send_response({ + error = "You can only discard " .. G.hand.config.highlighted_limit .. " cards", + error_code = BB_ERRORS.SCHEMA_INVALID_VALUE, + }) + return + end + + for _, card_index in ipairs(args.cards) do + if not G.hand.cards[card_index + 1] then + send_response({ + error = "Invalid card index: " .. card_index, + error_code = BB_ERRORS.SCHEMA_INVALID_VALUE, + }) + 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({ + no_delete = true, + 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 + local state_data = BB_GAMESTATE.get_gamestate() + send_response(state_data) + return true + end + + return false + end, + })) + end, +} From 4e1724557313ba9ac4f144bc6022af70f5a46624 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Mon, 24 Nov 2025 18:18:34 +0100 Subject: [PATCH 110/230] test(fixtures): add fixtures for discard endpoint tests --- tests/fixtures/generate.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/fixtures/generate.py b/tests/fixtures/generate.py index 367659e..0a0ab3e 100644 --- a/tests/fixtures/generate.py +++ b/tests/fixtures/generate.py @@ -107,6 +107,7 @@ def build_fixtures() -> list[FixtureSpec]: FIXTURES_DIR / "health" / "state-BLIND_SELECT.jkr", FIXTURES_DIR / "start" / "state-BLIND_SELECT.jkr", FIXTURES_DIR / "play" / "state-BLIND_SELECT.jkr", + FIXTURES_DIR / "discard" / "state-BLIND_SELECT.jkr", FIXTURES_DIR / "skip" / "state-BLIND_SELECT--blinds.small.status-SELECT.jkr", @@ -187,6 +188,19 @@ def build_fixtures() -> list[FixtureSpec]: ("set", {"hands": 1}), ], ), + FixtureSpec( + paths=[ + FIXTURES_DIR + / "discard" + / "state-SELECTING_HAND--round.discards_left-0.jkr", + ], + setup=[ + ("menu", {}), + ("start", {"deck": "RED", "stake": "WHITE", "seed": "TEST123"}), + ("select", {}), + ("set", {"discards": 0}), + ], + ), FixtureSpec( paths=[ FIXTURES_DIR From d7024c3f1af4a4bae71d039de58169520cbf8b16 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Mon, 24 Nov 2025 18:19:01 +0100 Subject: [PATCH 111/230] test(lua.endpoints): add tests for discard endpoint --- tests/lua/endpoints/test_discard.py | 113 ++++++++++++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 tests/lua/endpoints/test_discard.py diff --git a/tests/lua/endpoints/test_discard.py b/tests/lua/endpoints/test_discard.py new file mode 100644 index 0000000..b49e3ac --- /dev/null +++ b/tests/lua/endpoints/test_discard.py @@ -0,0 +1,113 @@ +"""Tests for src/lua/endpoints/discard.lua""" + +import socket + + +from tests.lua.conftest import api, assert_error_response, get_fixture_path + + +class TestDiscardEndpoint: + """Test basic discard endpoint functionality.""" + + def test_discard_zero_cards(self, client: socket.socket) -> None: + """Test discard endpoint with empty cards array.""" + save = "state-SELECTING_HAND.jkr" + api(client, "load", {"path": str(get_fixture_path("discard", save))}) + response = api(client, "discard", {"cards": []}) + assert_error_response( + response, + expected_error_code="SCHEMA_INVALID_VALUE", + expected_message_contains="Must provide at least one card to discard", + ) + + def test_discard_too_many_cards(self, client: socket.socket) -> None: + """Test discard endpoint with more cards than limit.""" + save = "state-SELECTING_HAND.jkr" + api(client, "load", {"path": str(get_fixture_path("discard", save))}) + response = api(client, "discard", {"cards": [0, 1, 2, 3, 4, 5]}) + assert_error_response( + response, + expected_error_code="SCHEMA_INVALID_VALUE", + expected_message_contains="You can only discard 5 cards", + ) + + def test_discard_out_of_range_cards(self, client: socket.socket) -> None: + """Test discard endpoint with invalid card index.""" + save = "state-SELECTING_HAND.jkr" + api(client, "load", {"path": str(get_fixture_path("discard", save))}) + response = api(client, "discard", {"cards": [999]}) + assert_error_response( + response, + expected_error_code="SCHEMA_INVALID_VALUE", + expected_message_contains="Invalid card index: 999", + ) + + def test_discard_no_discards_left(self, client: socket.socket) -> None: + """Test discard endpoint when no discards remain.""" + save = "state-SELECTING_HAND--round.discards_left-0.jkr" + api(client, "load", {"path": str(get_fixture_path("discard", save))}) + response = api(client, "discard", {"cards": [0]}) + assert_error_response( + response, + expected_error_code="SCHEMA_INVALID_VALUE", + expected_message_contains="No discards left", + ) + + def test_discard_valid_single_card(self, client: socket.socket) -> None: + """Test discard endpoint with valid single card.""" + save = "state-SELECTING_HAND.jkr" + api(client, "load", {"path": str(get_fixture_path("discard", save))}) + before = api(client, "gamestate", {}) + after = api(client, "discard", {"cards": [0]}) + assert after["state"] == "SELECTING_HAND" + assert after["round"]["discards_left"] == before["round"]["discards_left"] - 1 + + def test_discard_valid_multiple_cards(self, client: socket.socket) -> None: + """Test discard endpoint with valid multiple cards.""" + save = "state-SELECTING_HAND.jkr" + api(client, "load", {"path": str(get_fixture_path("discard", save))}) + before = api(client, "gamestate", {}) + after = api(client, "discard", {"cards": [1, 2, 3]}) + assert after["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: socket.socket): + """Test that discard fails when cards parameter is missing.""" + save = "state-SELECTING_HAND.jkr" + api(client, "load", {"path": str(get_fixture_path("discard", save))}) + response = api(client, "discard", {}) + assert_error_response( + response, + expected_error_code="SCHEMA_MISSING_REQUIRED", + expected_message_contains="Missing required field 'cards'", + ) + + def test_invalid_cards_type(self, client: socket.socket): + """Test that discard fails when cards parameter is not an array.""" + save = "state-SELECTING_HAND.jkr" + api(client, "load", {"path": str(get_fixture_path("discard", save))}) + response = api(client, "discard", {"cards": "INVALID_CARDS"}) + assert_error_response( + response, + expected_error_code="SCHEMA_INVALID_TYPE", + expected_message_contains="Field 'cards' must be an array", + ) + + +class TestDiscardEndpointStateRequirements: + """Test discard endpoint state requirements.""" + + def test_discard_from_BLIND_SELECT(self, client: socket.socket): + """Test that discard fails when not in SELECTING_HAND state.""" + save = "state-BLIND_SELECT.jkr" + api(client, "load", {"path": str(get_fixture_path("discard", save))}) + response = api(client, "discard", {"cards": [0]}) + assert_error_response( + response, + expected_error_code="STATE_INVALID_STATE", + expected_message_contains="Endpoint 'discard' requires one of these states:", + ) From 2b699f1c46db5fd418a767381ebac52520f24582 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Mon, 24 Nov 2025 18:20:04 +0100 Subject: [PATCH 112/230] chore(lua.endpoints): format imports in test_discard.py --- tests/lua/endpoints/test_discard.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/lua/endpoints/test_discard.py b/tests/lua/endpoints/test_discard.py index b49e3ac..84dfc04 100644 --- a/tests/lua/endpoints/test_discard.py +++ b/tests/lua/endpoints/test_discard.py @@ -2,7 +2,6 @@ import socket - from tests.lua.conftest import api, assert_error_response, get_fixture_path From 5ea895daf38af1330624b1227cc51deb819ecf57 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Mon, 24 Nov 2025 18:45:35 +0100 Subject: [PATCH 113/230] feat(lua.endpoints): add next_round endpoint --- balatrobot.lua | 2 ++ src/lua/endpoints/next_round.lua | 36 ++++++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+) create mode 100644 src/lua/endpoints/next_round.lua diff --git a/balatrobot.lua b/balatrobot.lua index c316fb1..978cbb6 100644 --- a/balatrobot.lua +++ b/balatrobot.lua @@ -25,6 +25,8 @@ BB_ENDPOINTS = { "src/lua/endpoints/discard.lua", -- Cash out endpoint "src/lua/endpoints/cash_out.lua", + -- Shop endpoints + "src/lua/endpoints/next_round.lua", -- If debug mode is enabled, debugger.lua will load test endpoints } diff --git a/src/lua/endpoints/next_round.lua b/src/lua/endpoints/next_round.lua new file mode 100644 index 0000000..7a8b076 --- /dev/null +++ b/src/lua/endpoints/next_round.lua @@ -0,0 +1,36 @@ +-- src/lua/endpoints/next_round.lua +-- Next Round Endpoint +-- +-- Leave the shop and advance to blind selection + +---@type Endpoint +return { + name = "next_round", + + description = "Leave the shop and advance to blind selection", + + schema = {}, + + requires_state = { G.STATES.SHOP }, + + ---@param _ table The arguments (none required) + ---@param send_response fun(response: table) Callback to send response + 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 done = G.STATE == G.STATES.BLIND_SELECT + if done then + sendDebugMessage("Return next_round() - reached BLIND_SELECT state", "BB.ENDPOINTS") + send_response(BB_GAMESTATE.get_gamestate()) + end + return done + end, + })) + end, +} From 27344f27a4bcacf9a7673dd751bdd7f8249b9ee8 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Mon, 24 Nov 2025 18:45:45 +0100 Subject: [PATCH 114/230] test(fixtures): add fixtures for next_round tests --- tests/fixtures/generate.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/fixtures/generate.py b/tests/fixtures/generate.py index 0a0ab3e..45fd836 100644 --- a/tests/fixtures/generate.py +++ b/tests/fixtures/generate.py @@ -108,6 +108,7 @@ def build_fixtures() -> list[FixtureSpec]: FIXTURES_DIR / "start" / "state-BLIND_SELECT.jkr", FIXTURES_DIR / "play" / "state-BLIND_SELECT.jkr", FIXTURES_DIR / "discard" / "state-BLIND_SELECT.jkr", + FIXTURES_DIR / "next_round" / "state-BLIND_SELECT.jkr", FIXTURES_DIR / "skip" / "state-BLIND_SELECT--blinds.small.status-SELECT.jkr", @@ -232,6 +233,7 @@ def build_fixtures() -> list[FixtureSpec]: FixtureSpec( paths=[ FIXTURES_DIR / "set" / "state-SHOP.jkr", + FIXTURES_DIR / "next_round" / "state-SHOP.jkr", ], setup=[ ("menu", {}), From 81ce318ac46d8f83289260114e07918ebc8e58d8 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Mon, 24 Nov 2025 18:45:59 +0100 Subject: [PATCH 115/230] test(lua.enpoints): add next_round endpoint tests test(lua.endpoints): add next_round endpoint tests --- tests/lua/endpoints/test_next_round.py | 47 ++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 tests/lua/endpoints/test_next_round.py diff --git a/tests/lua/endpoints/test_next_round.py b/tests/lua/endpoints/test_next_round.py new file mode 100644 index 0000000..c550a81 --- /dev/null +++ b/tests/lua/endpoints/test_next_round.py @@ -0,0 +1,47 @@ +"""Tests for src/lua/endpoints/next_round.lua""" + +import socket +from typing import Any + +import pytest + +from tests.lua.conftest import api, assert_error_response, get_fixture_path + + +def verify_next_round_response(response: dict[str, Any]) -> None: + """Verify that next_round response has expected fields.""" + # Verify state field - should transition to BLIND_SELECT + assert "state" in response + assert isinstance(response["state"], str) + assert response["state"] == "BLIND_SELECT" + + # Verify blinds field exists (we're at blind selection) + assert "blinds" in response + assert isinstance(response["blinds"], dict) + + +@pytest.mark.dev +class TestNextRoundEndpoint: + """Test basic next_round endpoint functionality.""" + + def test_next_round_from_shop(self, client: socket.socket) -> None: + """Test advancing to next round from SHOP state.""" + save = "state-SHOP.jkr" + api(client, "load", {"path": str(get_fixture_path("next_round", save))}) + response = api(client, "next_round", {}) + verify_next_round_response(response) + + +class TestNextRoundEndpointStateRequirements: + """Test next_round endpoint state requirements.""" + + def test_next_round_from_MENU(self, client: socket.socket): + """Test that next_round fails when not in SHOP state.""" + save = "state-BLIND_SELECT.jkr" + api(client, "load", {"path": str(get_fixture_path("next_round", save))}) + response = api(client, "next_round", {}) + assert_error_response( + response, + expected_error_code="STATE_INVALID_STATE", + expected_message_contains="Endpoint 'next_round' requires one of these states:", + ) From 8a3c5c585c99811883ef1fbc24ec4e7cc658c939 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Mon, 24 Nov 2025 19:05:27 +0100 Subject: [PATCH 116/230] chore(lua.enpoints): remove dev mark from next_round tests --- tests/lua/endpoints/test_next_round.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/tests/lua/endpoints/test_next_round.py b/tests/lua/endpoints/test_next_round.py index c550a81..1df1fe6 100644 --- a/tests/lua/endpoints/test_next_round.py +++ b/tests/lua/endpoints/test_next_round.py @@ -3,8 +3,6 @@ import socket from typing import Any -import pytest - from tests.lua.conftest import api, assert_error_response, get_fixture_path @@ -20,7 +18,6 @@ def verify_next_round_response(response: dict[str, Any]) -> None: assert isinstance(response["blinds"], dict) -@pytest.mark.dev class TestNextRoundEndpoint: """Test basic next_round endpoint functionality.""" From 1fefae7551972790ee738c7fcf919532d1e79a42 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Mon, 24 Nov 2025 19:14:27 +0100 Subject: [PATCH 117/230] feat(lua.endpoints): add reroll endpoint --- balatrobot.lua | 1 + src/lua/endpoints/reroll.lua | 47 ++++++++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+) create mode 100644 src/lua/endpoints/reroll.lua diff --git a/balatrobot.lua b/balatrobot.lua index 978cbb6..0b06336 100644 --- a/balatrobot.lua +++ b/balatrobot.lua @@ -27,6 +27,7 @@ BB_ENDPOINTS = { "src/lua/endpoints/cash_out.lua", -- Shop endpoints "src/lua/endpoints/next_round.lua", + "src/lua/endpoints/reroll.lua", -- If debug mode is enabled, debugger.lua will load test endpoints } diff --git a/src/lua/endpoints/reroll.lua b/src/lua/endpoints/reroll.lua new file mode 100644 index 0000000..cf6cdd2 --- /dev/null +++ b/src/lua/endpoints/reroll.lua @@ -0,0 +1,47 @@ +-- src/lua/endpoints/reroll.lua +-- Reroll Endpoint +-- +-- Reroll to update the cards in the shop area + +---@type Endpoint +return { + name = "reroll", + + description = "Reroll to update the cards in the shop area", + + schema = {}, + + requires_state = { G.STATES.SHOP }, + + ---@param _ table The arguments (none required) + ---@param send_response fun(response: table) Callback to send response + execute = function(_, send_response) + -- Check affordability + local reroll_cost = G.GAME.current_round and G.GAME.current_round.reroll_cost or 0 + + if G.GAME.dollars < reroll_cost then + send_response({ + error = "Not enough dollars to reroll. Current: " .. G.GAME.dollars .. ", Required: " .. reroll_cost, + error_code = BB_ERRORS.GAME_INVALID_STATE, + }) + 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, +} From f6317cabe19a4e1edd56fb59b96207eb1ae80078 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Mon, 24 Nov 2025 19:14:37 +0100 Subject: [PATCH 118/230] test(fixtures): add fixture for reroll tests --- tests/fixtures/generate.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tests/fixtures/generate.py b/tests/fixtures/generate.py index 45fd836..57efc29 100644 --- a/tests/fixtures/generate.py +++ b/tests/fixtures/generate.py @@ -109,6 +109,7 @@ def build_fixtures() -> list[FixtureSpec]: FIXTURES_DIR / "play" / "state-BLIND_SELECT.jkr", FIXTURES_DIR / "discard" / "state-BLIND_SELECT.jkr", FIXTURES_DIR / "next_round" / "state-BLIND_SELECT.jkr", + FIXTURES_DIR / "reroll" / "state-BLIND_SELECT.jkr", FIXTURES_DIR / "skip" / "state-BLIND_SELECT--blinds.small.status-SELECT.jkr", @@ -234,6 +235,7 @@ def build_fixtures() -> list[FixtureSpec]: paths=[ FIXTURES_DIR / "set" / "state-SHOP.jkr", FIXTURES_DIR / "next_round" / "state-SHOP.jkr", + FIXTURES_DIR / "reroll" / "state-SHOP.jkr", ], setup=[ ("menu", {}), @@ -244,6 +246,20 @@ def build_fixtures() -> list[FixtureSpec]: ("cash_out", {}), ], ), + FixtureSpec( + paths=[ + FIXTURES_DIR / "reroll" / "state-SHOP--money-0.jkr", + ], + setup=[ + ("menu", {}), + ("start", {"deck": "RED", "stake": "WHITE", "seed": "TEST123"}), + ("select", {}), + ("set", {"chips": 1000}), + ("play", {"cards": [0]}), + ("cash_out", {}), + ("set", {"money": 0}), + ], + ), ] From 1b7d9d709a7913feca0b9bc13f46e25c3d66685e Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Mon, 24 Nov 2025 19:15:10 +0100 Subject: [PATCH 119/230] test(lua.endpoints): add test for reroll endpoint --- tests/lua/endpoints/test_reroll.py | 45 ++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 tests/lua/endpoints/test_reroll.py diff --git a/tests/lua/endpoints/test_reroll.py b/tests/lua/endpoints/test_reroll.py new file mode 100644 index 0000000..cdaf7ef --- /dev/null +++ b/tests/lua/endpoints/test_reroll.py @@ -0,0 +1,45 @@ +"""Tests for src/lua/endpoints/reroll.lua""" + +import socket + +from tests.lua.conftest import api, assert_error_response, get_fixture_path + + +class TestRerollEndpoint: + """Test basic reroll endpoint functionality.""" + + def test_reroll_from_shop(self, client: socket.socket) -> None: + """Test rerolling shop from SHOP state.""" + save = "state-SHOP.jkr" + api(client, "load", {"path": str(get_fixture_path("reroll", save))}) + after = api(client, "gamestate", {}) + before = api(client, "reroll", {}) + assert after["state"] == "SHOP" + assert before["state"] == "SHOP" + assert after["shop"] != before["shop"] + + def test_reroll_insufficient_funds(self, client: socket.socket) -> None: + """Test reroll endpoint when player has insufficient funds.""" + save = "state-SHOP--money-0.jkr" + api(client, "load", {"path": str(get_fixture_path("reroll", save))}) + response = api(client, "reroll", {}) + assert_error_response( + response, + expected_error_code="GAME_INVALID_STATE", + expected_message_contains="Not enough dollars to reroll", + ) + + +class TestRerollEndpointStateRequirements: + """Test reroll endpoint state requirements.""" + + def test_reroll_from_BLIND_SELECT(self, client: socket.socket): + """Test that reroll fails when not in SHOP state.""" + save = "state-BLIND_SELECT.jkr" + api(client, "load", {"path": str(get_fixture_path("reroll", save))}) + response = api(client, "reroll", {}) + assert_error_response( + response, + expected_error_code="STATE_INVALID_STATE", + expected_message_contains="Endpoint 'reroll' requires one of these states:", + ) From ef600e6dc6043babbec3d7c115ac649ed74803b3 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Mon, 24 Nov 2025 19:19:15 +0100 Subject: [PATCH 120/230] chore(lua.endpoints): remove unused imports from test_play --- tests/lua/endpoints/test_play.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/lua/endpoints/test_play.py b/tests/lua/endpoints/test_play.py index 20795f3..43c6220 100644 --- a/tests/lua/endpoints/test_play.py +++ b/tests/lua/endpoints/test_play.py @@ -2,8 +2,6 @@ import socket -import pytest - from tests.lua.conftest import api, assert_error_response, get_fixture_path From 67f58085017789118f5af48ef312829f7a874417 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Mon, 24 Nov 2025 22:32:51 +0100 Subject: [PATCH 121/230] fix(lua.endpoints): properly check that the items in shop loaded --- src/lua/endpoints/cash_out.lua | 17 +++++++++++++---- src/lua/endpoints/load.lua | 17 +++++++++++++---- 2 files changed, 26 insertions(+), 8 deletions(-) diff --git a/src/lua/endpoints/cash_out.lua b/src/lua/endpoints/cash_out.lua index cf43372..e89bc91 100644 --- a/src/lua/endpoints/cash_out.lua +++ b/src/lua/endpoints/cash_out.lua @@ -19,6 +19,18 @@ return { 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", @@ -26,10 +38,7 @@ return { func = function() local done = false if G.STATE == G.STATES.SHOP and G.STATE_COMPLETE then - local done_vouchers = G.shop_vouchers and G.shop_vouchers.cards and #G.shop_vouchers.cards > 0 - local done_packs = G.shop_booster and G.shop_booster.cards and #G.shop_booster.cards > 0 - local done_jokers = G.shop_jokers and G.shop_jokers.cards and #G.shop_jokers.cards > 0 - done = done_vouchers or done_packs or done_jokers + 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()) diff --git a/src/lua/endpoints/load.lua b/src/lua/endpoints/load.lua index c204570..74ab505 100644 --- a/src/lua/endpoints/load.lua +++ b/src/lua/endpoints/load.lua @@ -83,6 +83,18 @@ return { -- 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", @@ -108,10 +120,7 @@ return { end if G.STATE == G.STATES.SHOP and G.STATE_COMPLETE then - local done_vouchers = G.shop_vouchers and G.shop_vouchers.cards and #G.shop_vouchers.cards > 0 - local done_packs = G.shop_booster and G.shop_booster.cards and #G.shop_booster.cards > 0 - local done_jokers = G.shop_jokers and G.shop_jokers.cards and #G.shop_jokers.cards > 0 - done = done_vouchers or done_packs or done_jokers + done = num_items(G.shop_booster) > 0 or num_items(G.shop_jokers) > 0 or num_items(G.shop_vouchers) > 0 end --- TODO: add other states here ... From 3c1195a4aa8ec594bc3657ac640a7872353b509c Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Tue, 25 Nov 2025 13:28:03 +0100 Subject: [PATCH 122/230] feat(lua.utils): improve enums in gamestate --- src/lua/utils/gamestate.lua | 4 + src/lua/utils/types.lua | 150 ++++++++++++++++++++++++++---------- 2 files changed, 114 insertions(+), 40 deletions(-) diff --git a/src/lua/utils/gamestate.lua b/src/lua/utils/gamestate.lua index 2df1841..26ffd62 100644 --- a/src/lua/utils/gamestate.lua +++ b/src/lua/utils/gamestate.lua @@ -239,8 +239,12 @@ local function extract_card(card) 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 diff --git a/src/lua/utils/types.lua b/src/lua/utils/types.lua index 38e4161..7ebfc77 100644 --- a/src/lua/utils/types.lua +++ b/src/lua/utils/types.lua @@ -1,4 +1,4 @@ ----@meta +---@meta types -- ========================================================================== -- Endpoint Type @@ -12,29 +12,9 @@ ---@field execute fun(args: table, send_response: fun(response: table)) Execute function -- ========================================================================== --- GameState Types +-- GameState Enums -- ========================================================================== ----@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 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 - ---@alias Deck ---| "RED" # +1 discard every round ---| "BLUE" # +1 hand every round @@ -63,17 +43,17 @@ ---| "GOLD" # 8. Shop can have Rental Jokers. Applies all previous Stakes ---@alias State ----| "SELECTING_HAND" # 1 ----| "HAND_PLAYED" # 2 ----| "DRAW_TO_HAND" # 3 ----| "GAME_OVER" # 4 ----| "SHOP" # 5 +---| "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 ----| "ROUND_EVAL" # 8 +---| "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 +---| "MENU" # 11 When in the main menu of the game ---| "TUTORIAL" # 12 ---| "SPLASH" # 13 ---| "SANDBOX" # 14 @@ -81,9 +61,99 @@ ---| "DEMO_CTA" # 16 ---| "STANDARD_PACK" # 17 ---| "BUFFOON_PACK" # 18 ----| "NEW_ROUND" # 19 +---| "NEW_ROUND" # 19 When a round is won and the new round begins ---| "SMODS_BOOSTER_OPENED" # 999 ----| "UNKNOWN" +---| "UNKNOWN" # Not a number, we never expect this game state + +---@alias Set +---| "BOOSTER" +---| "DEFAULT" +---| "EDITION" +---| "ENHANCED" +---| "JOKER" +---| "TAROT" +---| "PLANET" +---| "SPECTRAL" +---| "VOUCHER" + +---@alias Seal +---| "RED" +---| "BLUE" +---| "GOLD" +---| "PURPLE" + +---@alias Edition +---| "HOLO" +---| "FOIL" +---| "POLYCHROME" +---| "NEGATIVE" + +---@alias Enhancement +---| "BONUS" +---| "MULT" +---| "WILD" +---| "GLASS" +---| "STEEL" +---| "STONE" +---| "GOLD" +---| "LUCKY" + +---@alias Suit +---| "H" # Hearts +---| "D" # Diamonds +---| "C" # Clubs +---| "S" # Spades + +---@alias Rank +---| "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 + +---@alias Blind.Type +---| "SMALL" +---| "BIG" +---| "BOSS" + +---@alias Blind.Status +---| "SELECT" +---| "CURRENT" +---| "UPCOMING" +---| "DEFEATED" +---| "SKIPPED" + +-- ========================================================================== +-- GameState Types +-- ========================================================================== + +---@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 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 @@ -103,8 +173,8 @@ ---@field chips integer? Current chips scored in this round ---@class Blind ----@field type "SMALL" | "BIG" | "BOSS" Type of the blind ----@field status "SELECT" | "CURRENT" | "UPCOMING" | "DEFEATED" | "SKIPPED" Status of the bilnd +---@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 @@ -119,7 +189,7 @@ ---@class Card ---@field id integer Unique identifier for the card (sort_id) ----@field set "DEFAULT" | "JOKER" | "TAROT" | "PLANET" | "SPECTRAL" | "ENHANCED" | "BOOSTER" Card set/type +---@field set 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) @@ -127,14 +197,14 @@ ---@field cost Card.Cost Cost information (buy/sell prices) ---@class Card.Value ----@field suit "H" | "D" | "C" | "S"? Suit (Hearts, Diamonds, Clubs, Spades) - only for playing cards ----@field value "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" | "T" | "J" | "Q" | "K" | "A"? Rank - only for playing cards +---@field suit Suit? Suit (Hearts, Diamonds, Clubs, Spades) - only for playing cards +---@field value Rank? Rank - only for playing cards ---@field effect string Description of the card's effect (from UI) ---@class Card.Modifier ----@field seal "RED" | "BLUE" | "GOLD" | "PURPLE"? Seal type ----@field edition "HOLO" | "FOIL" | "POLYCHROME" | "NEGATIVE"? Edition type ----@field enhancement "BONUS" | "MULT" | "WILD" | "GLASS" | "STEEL" | "STONE" | "GOLD" | "LUCKY"? Enhancement type +---@field seal Seal? Seal type +---@field edition Edition? Edition type +---@field enhancement Enhancement? Enhancement type ---@field eternal boolean? If true, card cannot be sold or destroyed ---@field perishable integer? Number of rounds remaining (only if > 0) ---@field rental boolean? If true, card costs money at end of round From 4283eb5cc870e7e57bc47e18af26e6c69edee615 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Tue, 25 Nov 2025 18:13:28 +0100 Subject: [PATCH 123/230] feat(lua.utils): add pack field to gamestate --- src/lua/utils/gamestate.lua | 5 +++++ src/lua/utils/types.lua | 1 + 2 files changed, 6 insertions(+) diff --git a/src/lua/utils/gamestate.lua b/src/lua/utils/gamestate.lua index 26ffd62..6037cd7 100644 --- a/src/lua/utils/gamestate.lua +++ b/src/lua/utils/gamestate.lua @@ -694,6 +694,11 @@ function gamestate.get_gamestate() 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 diff --git a/src/lua/utils/types.lua b/src/lua/utils/types.lua index 7ebfc77..73b6f3f 100644 --- a/src/lua/utils/types.lua +++ b/src/lua/utils/types.lua @@ -150,6 +150,7 @@ ---@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) From 3872f449ee42f53d9f04345ded4fe6ebeafb03ac Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Tue, 25 Nov 2025 18:14:18 +0100 Subject: [PATCH 124/230] feat(lua.endpoints): add buy endpoint --- balatrobot.lua | 1 + src/lua/endpoints/buy.lua | 235 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 236 insertions(+) create mode 100644 src/lua/endpoints/buy.lua diff --git a/balatrobot.lua b/balatrobot.lua index 0b06336..fe8585e 100644 --- a/balatrobot.lua +++ b/balatrobot.lua @@ -28,6 +28,7 @@ BB_ENDPOINTS = { -- Shop endpoints "src/lua/endpoints/next_round.lua", "src/lua/endpoints/reroll.lua", + "src/lua/endpoints/buy.lua", -- If debug mode is enabled, debugger.lua will load test endpoints } diff --git a/src/lua/endpoints/buy.lua b/src/lua/endpoints/buy.lua new file mode 100644 index 0000000..59df0e8 --- /dev/null +++ b/src/lua/endpoints/buy.lua @@ -0,0 +1,235 @@ +-- src/lua/endpoints/buy.lua +-- Buy Endpoint +-- +-- Buy a card from the shop + +---@class Endpoint.Buy.Args +---@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 + +---@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 Endpoint.Buy.Args The arguments (card) + ---@param send_response fun(response: table) Callback to send response + 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.cards, "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({ + error = "Invalid arguments. You must provide one of: card, voucher, pack", + error_code = BB_ERRORS.SCHEMA_INVALID_VALUE, + }) + return + end + + -- Validate that only one of card, voucher, or pack is provided + if set > 1 then + send_response({ + error = "Invalid arguments. Cannot provide more than one of: card, voucher, or pack", + error_code = BB_ERRORS.SCHEMA_INVALID_VALUE, + }) + 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({ + error = msg, + error_code = BB_ERRORS.SCHEMA_INVALID_VALUE, + }) + return + end + + -- Validate card index is in range + if not area.cards[pos] then + send_response({ + error = "Card index out of range. Index: " .. args.card .. ", Available cards: " .. area.count, + error_code = BB_ERRORS.SCHEMA_INVALID_VALUE, + }) + return + end + + -- Get the card + local card = area.cards[pos] + + -- Check if the card can be afforded + if card.cost.buy > G.GAME.dollars then + send_response({ + error = "Card is not affordable. Cost: " .. card.cost.buy .. ", Current money: " .. gamestate.money, + error_code = BB_ERRORS.SCHEMA_INVALID_VALUE, + }) + return + end + + -- Ensure there is space in joker area + if card.set == "JOKER" then + if gamestate.jokers.count >= gamestate.jokers.limit then + send_response({ + error = "Cannot purchase joker card, joker slots are full. Current: " + .. gamestate.jokers.count + .. ", Limit: " + .. gamestate.jokers.limit, + error_code = BB_ERRORS.SCHEMA_INVALID_VALUE, + }) + 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({ + error = "Cannot purchase consumable card, consumable slots are full. Current: " + .. gamestate.consumables.count + .. ", Limit: " + .. gamestate.consumables.limit, + error_code = BB_ERRORS.SCHEMA_INVALID_VALUE, + }) + 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.cards 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({ + error = "No buy button found for card", + error_code = BB_ERRORS.GAME_INVALID_STATE, + }) + 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({ + no_delete = true, + trigger = "condition", + blocking = false, + func = function() + local done = false + + if args.card then + local shop_count = (G.shop_jokers and #G.shop_jokers.cards or 0) + local dest_count = (G.jokers and #G.jokers.cards or 0) + + (G.consumeables and #G.consumeables.cards or 0) + + (G.deck and #G.deck.cards 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.cards 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.cards 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, +} From 7b97f620dbb0904593bc1d3f11f13460f30ea2f6 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Tue, 25 Nov 2025 18:14:32 +0100 Subject: [PATCH 125/230] test(lua.endpoints): add tests for buy endpoint --- tests/lua/endpoints/test_buy.py | 197 ++++++++++++++++++++++++++++++++ 1 file changed, 197 insertions(+) create mode 100644 tests/lua/endpoints/test_buy.py diff --git a/tests/lua/endpoints/test_buy.py b/tests/lua/endpoints/test_buy.py new file mode 100644 index 0000000..88b6b06 --- /dev/null +++ b/tests/lua/endpoints/test_buy.py @@ -0,0 +1,197 @@ +"""Tests for src/lua/endpoints/buy.lua""" + +import socket + +import pytest + +from tests.lua.conftest import api, assert_error_response, get_fixture_path + + +class TestBuyEndpoint: + """Test basic buy endpoint functionality.""" + + @pytest.mark.flaky(reruns=2) + def test_buy_no_args(self, client: socket.socket) -> None: + """Test buy endpoint with no arguments.""" + save = "state-SHOP--shop.cards[0].set-JOKER.jkr" + api(client, "load", {"path": str(get_fixture_path("buy", save))}) + response = api(client, "buy", {}) + assert_error_response( + response, + expected_error_code="SCHEMA_INVALID_VALUE", + expected_message_contains="Invalid arguments. You must provide one of: card, voucher, pack", + ) + + @pytest.mark.flaky(reruns=2) + def test_buy_multi_args(self, client: socket.socket) -> None: + """Test buy endpoint with multiple arguments.""" + save = "state-SHOP--shop.cards[0].set-JOKER.jkr" + api(client, "load", {"path": str(get_fixture_path("buy", save))}) + response = api(client, "buy", {"card": 0, "voucher": 0}) + assert_error_response( + response, + expected_error_code="SCHEMA_INVALID_VALUE", + expected_message_contains="Invalid arguments. Cannot provide more than one of: card, voucher, or pack", + ) + + def test_buy_no_card_in_shop_area(self, client: socket.socket) -> None: + """Test buy endpoint with no card in shop area.""" + save = "state-SHOP--shop.count-0.jkr" + api(client, "load", {"path": str(get_fixture_path("buy", save))}) + response = api(client, "buy", {"card": 0}) + assert_error_response( + response, + expected_error_code="SCHEMA_INVALID_VALUE", + expected_message_contains="No jokers/consumables/cards in the shop. Reroll to restock the shop", + ) + + def test_buy_invalid_index(self, client: socket.socket) -> None: + """Test buy endpoint with invalid card index.""" + save = "state-SHOP--shop.cards[0].set-JOKER.jkr" + api(client, "load", {"path": str(get_fixture_path("buy", save))}) + response = api(client, "buy", {"card": 999}) + assert_error_response( + response, + expected_error_code="SCHEMA_INVALID_VALUE", + expected_message_contains="Card index out of range. Index: 999, Available cards: 2", + ) + + def test_buy_insufficient_funds(self, client: socket.socket) -> None: + """Test buy endpoint when player has insufficient funds.""" + save = "state-SHOP--money-0.jkr" + api(client, "load", {"path": str(get_fixture_path("buy", save))}) + response = api(client, "buy", {"card": 0}) + assert_error_response( + response, + expected_error_code="SCHEMA_INVALID_VALUE", + expected_message_contains="Card is not affordable. Cost: 5, Current money: 0", + ) + + def test_buy_joker_slots_full(self, client: socket.socket) -> None: + """Test buy endpoint when player has the maximum number of consumables.""" + save = "state-SHOP--jokers.count-5--shop.cards[0].set-JOKER.jkr" + api(client, "load", {"path": str(get_fixture_path("buy", save))}) + response = api(client, "buy", {"card": 0}) + assert_error_response( + response, + expected_error_code="SCHEMA_INVALID_VALUE", + expected_message_contains="Cannot purchase joker card, joker slots are full. Current: 5, Limit: 5", + ) + + def test_buy_consumable_slots_full(self, client: socket.socket) -> None: + """Test buy endpoint when player has the maximum number of consumables.""" + save = "state-SHOP--consumables.count-2--shop.cards[1].set-PLANET.jkr" + api(client, "load", {"path": str(get_fixture_path("buy", save))}) + response = api(client, "buy", {"card": 1}) + assert_error_response( + response, + expected_error_code="SCHEMA_INVALID_VALUE", + expected_message_contains="Cannot purchase consumable card, consumable slots are full. Current: 2, Limit: 2", + ) + + def test_buy_vouchers_slot_empty(self, client: socket.socket) -> None: + """Test buy endpoint when player has the maximum number of vouchers.""" + save = "state-SHOP--voucher.count-0.jkr" + api(client, "load", {"path": str(get_fixture_path("buy", save))}) + response = api(client, "buy", {"voucher": 0}) + assert_error_response( + response, + expected_error_code="SCHEMA_INVALID_VALUE", + expected_message_contains="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: socket.socket) -> None: + """Test buy endpoint when player has the maximum number of vouchers.""" + save = "state-SHOP--packs.count-0.jkr" + api(client, "load", {"path": str(get_fixture_path("buy", save))}) + response = api(client, "buy", {"voucher": 0}) + assert_error_response( + response, + expected_error_code="SCHEMA_INVALID_VALUE", + expected_message_contains="No vouchers to redeem. Defeat boss blind to restock", + ) + + def test_buy_joker_success(self, client: socket.socket) -> None: + """Test buying a joker card from shop.""" + save = "state-SHOP--shop.cards[0].set-JOKER.jkr" + api(client, "load", {"path": str(get_fixture_path("buy", save))}) + response = api(client, "buy", {"card": 0}) + assert response["jokers"]["cards"][0]["set"] == "JOKER" + + def test_buy_consumable_success(self, client: socket.socket) -> None: + """Test buying a consumable card (Planet/Tarot/Spectral) from shop.""" + save = "state-SHOP--shop.cards[1].set-PLANET.jkr" + api(client, "load", {"path": str(get_fixture_path("buy", save))}) + response = api(client, "buy", {"card": 1}) + assert response["consumables"]["cards"][0]["set"] == "PLANET" + + def test_buy_voucher_success(self, client: socket.socket) -> None: + """Test buying a voucher from shop.""" + save = "state-SHOP--voucher.cards[0].set-VOUCHER.jkr" + api(client, "load", {"path": str(get_fixture_path("buy", save))}) + response = api(client, "buy", {"voucher": 0}) + assert response["used_vouchers"] is not None + assert len(response["used_vouchers"]) > 0 + + def test_buy_packs_success(self, client: socket.socket) -> None: + """Test buying a pack from shop.""" + save = "state-SHOP--packs.cards[0].label-Buffoon+Pack--packs.cards[1].label-Standard+Pack.jkr" + api(client, "load", {"path": str(get_fixture_path("buy", save))}) + response = api(client, "buy", {"pack": 0}) + assert response["pack"] is not None + assert len(response["pack"]["cards"]) > 0 + + +class TestBuyEndpointValidation: + """Test buy endpoint parameter validation.""" + + def test_invalid_card_type_string(self, client: socket.socket) -> None: + """Test that buy fails when card parameter is a string instead of integer.""" + save = "state-SHOP--shop.cards[0].set-JOKER.jkr" + api(client, "load", {"path": str(get_fixture_path("buy", save))}) + response = api(client, "buy", {"card": "INVALID_STRING"}) + assert_error_response( + response, + expected_error_code="SCHEMA_INVALID_TYPE", + expected_message_contains="Field 'card' must be an integer", + ) + + def test_invalid_voucher_type_string(self, client: socket.socket) -> None: + """Test that buy fails when voucher parameter is a string instead of integer.""" + save = "state-SHOP--shop.cards[0].set-JOKER.jkr" + api(client, "load", {"path": str(get_fixture_path("buy", save))}) + response = api(client, "buy", {"voucher": "INVALID_STRING"}) + assert_error_response( + response, + expected_error_code="SCHEMA_INVALID_TYPE", + expected_message_contains="Field 'voucher' must be an integer", + ) + + def test_invalid_pack_type_string(self, client: socket.socket) -> None: + """Test that buy fails when pack parameter is a string instead of integer.""" + save = "state-SHOP--shop.cards[0].set-JOKER.jkr" + api(client, "load", {"path": str(get_fixture_path("buy", save))}) + response = api(client, "buy", {"pack": "INVALID_STRING"}) + assert_error_response( + response, + expected_error_code="SCHEMA_INVALID_TYPE", + expected_message_contains="Field 'pack' must be an integer", + ) + + +class TestBuyEndpointStateRequirements: + """Test buy endpoint state requirements.""" + + def test_buy_from_BLIND_SELECT(self, client: socket.socket) -> None: + """Test that buy fails when not in SHOP state.""" + save = "state-BLIND_SELECT.jkr" + api(client, "load", {"path": str(get_fixture_path("buy", save))}) + response = api(client, "buy", {"card": 0}) + assert_error_response( + response, + expected_error_code="STATE_INVALID_STATE", + expected_message_contains="Endpoint 'buy' requires one of these states:", + ) From 1fe44ba0d028047e09f522c7f0a5ebf621ff8085 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Tue, 25 Nov 2025 18:14:42 +0100 Subject: [PATCH 126/230] test(fixtures): add fixtures for buy endpoint tests --- tests/fixtures/generate.py | 95 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 95 insertions(+) diff --git a/tests/fixtures/generate.py b/tests/fixtures/generate.py index 57efc29..e9f469b 100644 --- a/tests/fixtures/generate.py +++ b/tests/fixtures/generate.py @@ -110,6 +110,7 @@ def build_fixtures() -> list[FixtureSpec]: FIXTURES_DIR / "discard" / "state-BLIND_SELECT.jkr", FIXTURES_DIR / "next_round" / "state-BLIND_SELECT.jkr", FIXTURES_DIR / "reroll" / "state-BLIND_SELECT.jkr", + FIXTURES_DIR / "buy" / "state-BLIND_SELECT.jkr", FIXTURES_DIR / "skip" / "state-BLIND_SELECT--blinds.small.status-SELECT.jkr", @@ -236,6 +237,12 @@ def build_fixtures() -> list[FixtureSpec]: FIXTURES_DIR / "set" / "state-SHOP.jkr", FIXTURES_DIR / "next_round" / "state-SHOP.jkr", FIXTURES_DIR / "reroll" / "state-SHOP.jkr", + FIXTURES_DIR / "buy" / "state-SHOP--shop.cards[0].set-JOKER.jkr", + FIXTURES_DIR / "buy" / "state-SHOP--shop.cards[1].set-PLANET.jkr", + FIXTURES_DIR / "buy" / "state-SHOP--voucher.cards[0].set-VOUCHER.jkr", + FIXTURES_DIR + / "buy" + / "state-SHOP--packs.cards[0].label-Buffoon+Pack--packs.cards[1].label-Standard+Pack.jkr", ], setup=[ ("menu", {}), @@ -246,9 +253,97 @@ def build_fixtures() -> list[FixtureSpec]: ("cash_out", {}), ], ), + FixtureSpec( + paths=[ + FIXTURES_DIR / "buy" / "state-SHOP--voucher.count-0.jkr", + ], + setup=[ + ("menu", {}), + ("start", {"deck": "RED", "stake": "WHITE", "seed": "TEST123"}), + ("select", {}), + ("set", {"chips": 1000, "money": 100}), + ("play", {"cards": [0]}), + ("cash_out", {}), + ("buy", {"voucher": 0}), + ], + ), + FixtureSpec( + paths=[ + FIXTURES_DIR / "buy" / "state-SHOP--shop.cards[1].set-TAROT.jkr", + ], + setup=[ + ("menu", {}), + ("start", {"deck": "RED", "stake": "WHITE", "seed": "TEST123"}), + ("select", {}), + ("set", {"chips": 1000, "money": 100}), + ("play", {"cards": [0]}), + ("cash_out", {}), + ("reroll", {}), + ], + ), + FixtureSpec( + paths=[ + FIXTURES_DIR / "buy" / "state-SHOP--shop.count-0.jkr", + ], + setup=[ + ("menu", {}), + ("start", {"deck": "RED", "stake": "WHITE", "seed": "TEST123"}), + ("select", {}), + ("set", {"chips": 1000, "money": 100}), + ("play", {"cards": [0]}), + ("cash_out", {}), + ("buy", {"card": 0}), + ("buy", {"card": 0}), + ], + ), + FixtureSpec( + paths=[ + FIXTURES_DIR + / "buy" + / "state-SHOP--jokers.count-5--shop.cards[0].set-JOKER.jkr", + ], + setup=[ + ("menu", {}), + ("start", {"deck": "RED", "stake": "WHITE", "seed": "TEST123"}), + ("select", {}), + ("set", {"chips": 1000, "money": 1000}), + ("play", {"cards": [0]}), + ("cash_out", {}), + ("buy", {"card": 0}), + ("reroll", {}), + ("buy", {"card": 0}), + ("reroll", {}), + ("buy", {"card": 0}), + ("buy", {"card": 0}), + ("reroll", {}), + ("buy", {"card": 0}), + ], + ), + FixtureSpec( + paths=[ + FIXTURES_DIR + / "buy" + / "state-SHOP--consumables.count-2--shop.cards[1].set-PLANET.jkr", + ], + setup=[ + ("menu", {}), + ("start", {"deck": "RED", "stake": "WHITE", "seed": "TEST123"}), + ("select", {}), + ("set", {"chips": 1000, "money": 100}), + ("play", {"cards": [0]}), + ("cash_out", {}), + ("buy", {"card": 1}), + ("reroll", {}), + ("buy", {"card": 1}), + ("reroll", {}), + ("reroll", {}), + ("reroll", {}), + ], + ), FixtureSpec( paths=[ FIXTURES_DIR / "reroll" / "state-SHOP--money-0.jkr", + FIXTURES_DIR / "buy" / "state-SHOP--money-0.jkr", ], setup=[ ("menu", {}), From b4489fcc36155186b4b12ad35805cdd30f99a7a5 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Wed, 26 Nov 2025 14:41:58 +0100 Subject: [PATCH 127/230] refactor(fixtures): refactor fixtures generation using JSON --- tests/fixtures/README.md | 61 -- tests/fixtures/fixtures.json | 1112 +++++++++++++++++++++++++++ tests/fixtures/fixtures.schema.json | 23 + tests/fixtures/generate.py | 390 ++-------- 4 files changed, 1192 insertions(+), 394 deletions(-) delete mode 100644 tests/fixtures/README.md create mode 100644 tests/fixtures/fixtures.json create mode 100644 tests/fixtures/fixtures.schema.json diff --git a/tests/fixtures/README.md b/tests/fixtures/README.md deleted file mode 100644 index ec3a453..0000000 --- a/tests/fixtures/README.md +++ /dev/null @@ -1,61 +0,0 @@ -# Test Fixtures - -This directory contains test fixture files (`.jkr` save files) used for testing the save and load endpoints. - -Fixtures are organized hierarchically by endpoint: - -``` -tests/fixtures/ -├── save/ # Fixtures for save endpoint tests -├── load/ # Fixtures for load endpoint tests -├── generate.py # Script to generate all fixtures -└── README.md -``` - -## Generating Fixtures - -### Prerequisites - -1. Start Balatro with the BalatroBot mod loaded -2. Make sure you're in an appropriate game state for the fixtures you need - -### Generate All Fixtures - -```bash -python tests/fixtures/generate.py -``` - -The script will automatically connect to Balatro on localhost:12346 and generate all required fixtures. - -## Adding New Fixtures - -To add new fixtures: - -1. Create the appropriate directory structure under the endpoint category -2. Update `generate.py` to include the new fixture generation logic -3. Add fixture descriptions to this README - -## Usage in Tests - -Fixtures are accessed using the `get_fixture_path()` helper function: - -```python -from tests.lua.conftest import get_fixture_path - -def test_example(client): - fixture_path = get_fixture_path("load", "start.jkr") - send_request(client, "load", {"path": str(fixture_path)}) - response = receive_response(client) - assert response["success"] is True -``` - -## Current Fixtures - -### Save Endpoint Tests (`save/`) - -- `start.jkr` - Valid save file from initial game state - -### Load Endpoint Tests (`load/`) - -- `start.jkr` - Valid save file from initial game state -- `corrupted.jkr` - Intentionally corrupted save file for error testing diff --git a/tests/fixtures/fixtures.json b/tests/fixtures/fixtures.json new file mode 100644 index 0000000..7df391e --- /dev/null +++ b/tests/fixtures/fixtures.json @@ -0,0 +1,1112 @@ +{ + "$schema": "./fixtures.schema.json", + "health": { + "state-BLIND_SELECT": [ + { + "endpoint": "menu", + "arguments": {} + }, + { + "endpoint": "start", + "arguments": { + "deck": "RED", + "stake": "WHITE", + "seed": "TEST123" + } + } + ] + }, + "gamestate": { + "state-BLIND_SELECT--round_num-0--deck-RED--stake-WHITE": [ + { + "endpoint": "menu", + "arguments": {} + }, + { + "endpoint": "start", + "arguments": { + "deck": "RED", + "stake": "WHITE", + "seed": "TEST123" + } + } + ] + }, + "save": { + "state-BLIND_SELECT": [ + { + "endpoint": "menu", + "arguments": {} + }, + { + "endpoint": "start", + "arguments": { + "deck": "RED", + "stake": "WHITE", + "seed": "TEST123" + } + } + ] + }, + "load": { + "state-BLIND_SELECT": [ + { + "endpoint": "menu", + "arguments": {} + }, + { + "endpoint": "start", + "arguments": { + "deck": "RED", + "stake": "WHITE", + "seed": "TEST123" + } + } + ] + }, + "set": { + "state-SELECTING_HAND": [ + { + "endpoint": "menu", + "arguments": {} + }, + { + "endpoint": "start", + "arguments": { + "deck": "RED", + "stake": "WHITE", + "seed": "TEST123" + } + }, + { + "endpoint": "select", + "arguments": {} + } + ], + "state-SHOP": [ + { + "endpoint": "menu", + "arguments": {} + }, + { + "endpoint": "start", + "arguments": { + "deck": "RED", + "stake": "WHITE", + "seed": "TEST123" + } + }, + { + "endpoint": "select", + "arguments": {} + }, + { + "endpoint": "set", + "arguments": { + "chips": 1000 + } + }, + { + "endpoint": "play", + "arguments": { + "cards": [ + 0 + ] + } + }, + { + "endpoint": "cash_out", + "arguments": {} + } + ] + }, + "menu": { + "state-BLIND_SELECT": [ + { + "endpoint": "menu", + "arguments": {} + }, + { + "endpoint": "start", + "arguments": { + "deck": "RED", + "stake": "WHITE", + "seed": "TEST123" + } + } + ] + }, + "start": { + "state-BLIND_SELECT": [ + { + "endpoint": "menu", + "arguments": {} + }, + { + "endpoint": "start", + "arguments": { + "deck": "RED", + "stake": "WHITE", + "seed": "TEST123" + } + } + ] + }, + "skip": { + "state-BLIND_SELECT--blinds.small.status-SELECT": [ + { + "endpoint": "menu", + "arguments": {} + }, + { + "endpoint": "start", + "arguments": { + "deck": "RED", + "stake": "WHITE", + "seed": "TEST123" + } + } + ], + "state-BLIND_SELECT--blinds.big.status-SELECT": [ + { + "endpoint": "menu", + "arguments": {} + }, + { + "endpoint": "start", + "arguments": { + "deck": "RED", + "stake": "WHITE", + "seed": "TEST123" + } + }, + { + "endpoint": "skip", + "arguments": {} + } + ], + "state-BLIND_SELECT--blinds.boss.status-SELECT": [ + { + "endpoint": "menu", + "arguments": {} + }, + { + "endpoint": "start", + "arguments": { + "deck": "RED", + "stake": "WHITE", + "seed": "TEST123" + } + }, + { + "endpoint": "skip", + "arguments": {} + }, + { + "endpoint": "skip", + "arguments": {} + } + ] + }, + "select": { + "state-BLIND_SELECT--blinds.small.status-SELECT": [ + { + "endpoint": "menu", + "arguments": {} + }, + { + "endpoint": "start", + "arguments": { + "deck": "RED", + "stake": "WHITE", + "seed": "TEST123" + } + } + ], + "state-BLIND_SELECT--blinds.big.status-SELECT": [ + { + "endpoint": "menu", + "arguments": {} + }, + { + "endpoint": "start", + "arguments": { + "deck": "RED", + "stake": "WHITE", + "seed": "TEST123" + } + }, + { + "endpoint": "skip", + "arguments": {} + } + ], + "state-BLIND_SELECT--blinds.boss.status-SELECT": [ + { + "endpoint": "menu", + "arguments": {} + }, + { + "endpoint": "start", + "arguments": { + "deck": "RED", + "stake": "WHITE", + "seed": "TEST123" + } + }, + { + "endpoint": "skip", + "arguments": {} + }, + { + "endpoint": "skip", + "arguments": {} + } + ] + }, + "play": { + "state-BLIND_SELECT": [ + { + "endpoint": "menu", + "arguments": {} + }, + { + "endpoint": "start", + "arguments": { + "deck": "RED", + "stake": "WHITE", + "seed": "TEST123" + } + } + ], + "state-SELECTING_HAND": [ + { + "endpoint": "menu", + "arguments": {} + }, + { + "endpoint": "start", + "arguments": { + "deck": "RED", + "stake": "WHITE", + "seed": "TEST123" + } + }, + { + "endpoint": "select", + "arguments": {} + } + ], + "state-SELECTING_HAND--round.chips-200": [ + { + "endpoint": "menu", + "arguments": {} + }, + { + "endpoint": "start", + "arguments": { + "deck": "RED", + "stake": "WHITE", + "seed": "TEST123" + } + }, + { + "endpoint": "select", + "arguments": {} + }, + { + "endpoint": "set", + "arguments": { + "chips": 200, + "hands": 1, + "discards": 0 + } + } + ], + "state-SELECTING_HAND--round.hands_left-1": [ + { + "endpoint": "menu", + "arguments": {} + }, + { + "endpoint": "start", + "arguments": { + "deck": "RED", + "stake": "WHITE", + "seed": "TEST123" + } + }, + { + "endpoint": "select", + "arguments": {} + }, + { + "endpoint": "set", + "arguments": { + "hands": 1 + } + } + ], + "state-SELECTING_HAND--ante_num-8--blinds.boss.status-CURRENT--round.chips-1000000": [ + { + "endpoint": "menu", + "arguments": {} + }, + { + "endpoint": "start", + "arguments": { + "deck": "RED", + "stake": "WHITE", + "seed": "TEST123" + } + }, + { + "endpoint": "skip", + "arguments": {} + }, + { + "endpoint": "skip", + "arguments": {} + }, + { + "endpoint": "select", + "arguments": {} + }, + { + "endpoint": "set", + "arguments": { + "ante": 8, + "chips": 1000000 + } + } + ] + }, + "discard": { + "state-BLIND_SELECT": [ + { + "endpoint": "menu", + "arguments": {} + }, + { + "endpoint": "start", + "arguments": { + "deck": "RED", + "stake": "WHITE", + "seed": "TEST123" + } + } + ], + "state-SELECTING_HAND": [ + { + "endpoint": "menu", + "arguments": {} + }, + { + "endpoint": "start", + "arguments": { + "deck": "RED", + "stake": "WHITE", + "seed": "TEST123" + } + }, + { + "endpoint": "select", + "arguments": {} + } + ], + "state-SELECTING_HAND--round.discards_left-0": [ + { + "endpoint": "menu", + "arguments": {} + }, + { + "endpoint": "start", + "arguments": { + "deck": "RED", + "stake": "WHITE", + "seed": "TEST123" + } + }, + { + "endpoint": "select", + "arguments": {} + }, + { + "endpoint": "set", + "arguments": { + "discards": 0 + } + } + ] + }, + "cash_out": { + "state-BLIND_SELECT": [ + { + "endpoint": "menu", + "arguments": {} + }, + { + "endpoint": "start", + "arguments": { + "deck": "RED", + "stake": "WHITE", + "seed": "TEST123" + } + } + ], + "state-ROUND_EVAL": [ + { + "endpoint": "menu", + "arguments": {} + }, + { + "endpoint": "start", + "arguments": { + "deck": "RED", + "stake": "WHITE", + "seed": "TEST123" + } + }, + { + "endpoint": "select", + "arguments": {} + }, + { + "endpoint": "set", + "arguments": { + "chips": 1000 + } + }, + { + "endpoint": "play", + "arguments": { + "cards": [ + 0 + ] + } + } + ] + }, + "next_round": { + "state-BLIND_SELECT": [ + { + "endpoint": "menu", + "arguments": {} + }, + { + "endpoint": "start", + "arguments": { + "deck": "RED", + "stake": "WHITE", + "seed": "TEST123" + } + } + ], + "state-SHOP": [ + { + "endpoint": "menu", + "arguments": {} + }, + { + "endpoint": "start", + "arguments": { + "deck": "RED", + "stake": "WHITE", + "seed": "TEST123" + } + }, + { + "endpoint": "select", + "arguments": {} + }, + { + "endpoint": "set", + "arguments": { + "chips": 1000 + } + }, + { + "endpoint": "play", + "arguments": { + "cards": [ + 0 + ] + } + }, + { + "endpoint": "cash_out", + "arguments": {} + } + ] + }, + "reroll": { + "state-BLIND_SELECT": [ + { + "endpoint": "menu", + "arguments": {} + }, + { + "endpoint": "start", + "arguments": { + "deck": "RED", + "stake": "WHITE", + "seed": "TEST123" + } + } + ], + "state-SHOP": [ + { + "endpoint": "menu", + "arguments": {} + }, + { + "endpoint": "start", + "arguments": { + "deck": "RED", + "stake": "WHITE", + "seed": "TEST123" + } + }, + { + "endpoint": "select", + "arguments": {} + }, + { + "endpoint": "set", + "arguments": { + "chips": 1000 + } + }, + { + "endpoint": "play", + "arguments": { + "cards": [ + 0 + ] + } + }, + { + "endpoint": "cash_out", + "arguments": {} + } + ], + "state-SHOP--money-0": [ + { + "endpoint": "menu", + "arguments": {} + }, + { + "endpoint": "start", + "arguments": { + "deck": "RED", + "stake": "WHITE", + "seed": "TEST123" + } + }, + { + "endpoint": "select", + "arguments": {} + }, + { + "endpoint": "set", + "arguments": { + "chips": 1000 + } + }, + { + "endpoint": "play", + "arguments": { + "cards": [ + 0 + ] + } + }, + { + "endpoint": "cash_out", + "arguments": {} + }, + { + "endpoint": "set", + "arguments": { + "money": 0 + } + } + ] + }, + "buy": { + "state-BLIND_SELECT": [ + { + "endpoint": "menu", + "arguments": {} + }, + { + "endpoint": "start", + "arguments": { + "deck": "RED", + "stake": "WHITE", + "seed": "TEST123" + } + } + ], + "state-SHOP--shop.cards[0].set-JOKER": [ + { + "endpoint": "menu", + "arguments": {} + }, + { + "endpoint": "start", + "arguments": { + "deck": "RED", + "stake": "WHITE", + "seed": "TEST123" + } + }, + { + "endpoint": "select", + "arguments": {} + }, + { + "endpoint": "set", + "arguments": { + "chips": 1000 + } + }, + { + "endpoint": "play", + "arguments": { + "cards": [ + 0 + ] + } + }, + { + "endpoint": "cash_out", + "arguments": {} + } + ], + "state-SHOP--shop.cards[1].set-PLANET": [ + { + "endpoint": "menu", + "arguments": {} + }, + { + "endpoint": "start", + "arguments": { + "deck": "RED", + "stake": "WHITE", + "seed": "TEST123" + } + }, + { + "endpoint": "select", + "arguments": {} + }, + { + "endpoint": "set", + "arguments": { + "chips": 1000 + } + }, + { + "endpoint": "play", + "arguments": { + "cards": [ + 0 + ] + } + }, + { + "endpoint": "cash_out", + "arguments": {} + } + ], + "state-SHOP--voucher.cards[0].set-VOUCHER": [ + { + "endpoint": "menu", + "arguments": {} + }, + { + "endpoint": "start", + "arguments": { + "deck": "RED", + "stake": "WHITE", + "seed": "TEST123" + } + }, + { + "endpoint": "select", + "arguments": {} + }, + { + "endpoint": "set", + "arguments": { + "chips": 1000 + } + }, + { + "endpoint": "play", + "arguments": { + "cards": [ + 0 + ] + } + }, + { + "endpoint": "cash_out", + "arguments": {} + } + ], + "state-SHOP--packs.cards[0].label-Buffoon+Pack--packs.cards[1].label-Standard+Pack": [ + { + "endpoint": "menu", + "arguments": {} + }, + { + "endpoint": "start", + "arguments": { + "deck": "RED", + "stake": "WHITE", + "seed": "TEST123" + } + }, + { + "endpoint": "select", + "arguments": {} + }, + { + "endpoint": "set", + "arguments": { + "chips": 1000 + } + }, + { + "endpoint": "play", + "arguments": { + "cards": [ + 0 + ] + } + }, + { + "endpoint": "cash_out", + "arguments": {} + } + ], + "state-SHOP--voucher.count-0": [ + { + "endpoint": "menu", + "arguments": {} + }, + { + "endpoint": "start", + "arguments": { + "deck": "RED", + "stake": "WHITE", + "seed": "TEST123" + } + }, + { + "endpoint": "select", + "arguments": {} + }, + { + "endpoint": "set", + "arguments": { + "chips": 1000 + } + }, + { + "endpoint": "play", + "arguments": { + "cards": [ + 0 + ] + } + }, + { + "endpoint": "cash_out", + "arguments": {} + }, + { + "endpoint": "buy", + "arguments": { + "voucher": 0 + } + } + ], + "state-SHOP--shop.cards[1].set-TAROT": [ + { + "endpoint": "menu", + "arguments": {} + }, + { + "endpoint": "start", + "arguments": { + "deck": "RED", + "stake": "WHITE", + "seed": "TEST123" + } + }, + { + "endpoint": "select", + "arguments": {} + }, + { + "endpoint": "set", + "arguments": { + "chips": 1000 + } + }, + { + "endpoint": "play", + "arguments": { + "cards": [ + 0 + ] + } + }, + { + "endpoint": "cash_out", + "arguments": {} + }, + { + "endpoint": "reroll", + "arguments": {} + } + ], + "state-SHOP--shop.count-0": [ + { + "endpoint": "menu", + "arguments": {} + }, + { + "endpoint": "start", + "arguments": { + "deck": "RED", + "stake": "WHITE", + "seed": "TEST123" + } + }, + { + "endpoint": "select", + "arguments": {} + }, + { + "endpoint": "set", + "arguments": { + "chips": 1000 + } + }, + { + "endpoint": "play", + "arguments": { + "cards": [ + 0 + ] + } + }, + { + "endpoint": "cash_out", + "arguments": {} + }, + { + "endpoint": "buy", + "arguments": { + "card": 0 + } + }, + { + "endpoint": "buy", + "arguments": { + "card": 0 + } + } + ], + "state-SHOP--jokers.count-5--shop.cards[0].set-JOKER": [ + { + "endpoint": "menu", + "arguments": {} + }, + { + "endpoint": "start", + "arguments": { + "deck": "RED", + "stake": "WHITE", + "seed": "TEST123" + } + }, + { + "endpoint": "select", + "arguments": {} + }, + { + "endpoint": "set", + "arguments": { + "chips": 1000, + "money": 1000 + } + }, + { + "endpoint": "play", + "arguments": { + "cards": [ + 0 + ] + } + }, + { + "endpoint": "cash_out", + "arguments": {} + }, + { + "endpoint": "buy", + "arguments": { + "card": 0 + } + }, + { + "endpoint": "reroll", + "arguments": {} + }, + { + "endpoint": "buy", + "arguments": { + "card": 0 + } + }, + { + "endpoint": "reroll", + "arguments": {} + }, + { + "endpoint": "buy", + "arguments": { + "card": 0 + } + }, + { + "endpoint": "buy", + "arguments": { + "card": 0 + } + }, + { + "endpoint": "reroll", + "arguments": {} + }, + { + "endpoint": "buy", + "arguments": { + "card": 0 + } + } + ], + "state-SHOP--consumables.count-2--shop.cards[1].set-PLANET": [ + { + "endpoint": "menu", + "arguments": {} + }, + { + "endpoint": "start", + "arguments": { + "deck": "RED", + "stake": "WHITE", + "seed": "TEST123" + } + }, + { + "endpoint": "select", + "arguments": {} + }, + { + "endpoint": "set", + "arguments": { + "chips": 1000, + "money": 1000 + } + }, + { + "endpoint": "play", + "arguments": { + "cards": [ + 0 + ] + } + }, + { + "endpoint": "cash_out", + "arguments": {} + }, + { + "endpoint": "buy", + "arguments": { + "card": 1 + } + }, + { + "endpoint": "reroll", + "arguments": {} + }, + { + "endpoint": "buy", + "arguments": { + "card": 1 + } + }, + { + "endpoint": "reroll", + "arguments": {} + }, + { + "endpoint": "reroll", + "arguments": {} + }, + { + "endpoint": "reroll", + "arguments": {} + } + ], + "state-SHOP--money-0": [ + { + "endpoint": "menu", + "arguments": {} + }, + { + "endpoint": "start", + "arguments": { + "deck": "RED", + "stake": "WHITE", + "seed": "TEST123" + } + }, + { + "endpoint": "select", + "arguments": {} + }, + { + "endpoint": "set", + "arguments": { + "chips": 1000 + } + }, + { + "endpoint": "play", + "arguments": { + "cards": [ + 0 + ] + } + }, + { + "endpoint": "cash_out", + "arguments": {} + }, + { + "endpoint": "set", + "arguments": { + "money": 0 + } + } + ] + } +} diff --git a/tests/fixtures/fixtures.schema.json b/tests/fixtures/fixtures.schema.json new file mode 100644 index 0000000..15fbaa4 --- /dev/null +++ b/tests/fixtures/fixtures.schema.json @@ -0,0 +1,23 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Balatro API Test Fixtures Schema", + "type": "object", + "additionalProperties": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "type": "object", + "required": ["endpoint", "arguments"], + "properties": { + "endpoint": { + "type": "string" + }, + "arguments": { + "type": "object" + } + } + } + } + } +} diff --git a/tests/fixtures/generate.py b/tests/fixtures/generate.py index e9f469b..c349983 100644 --- a/tests/fixtures/generate.py +++ b/tests/fixtures/generate.py @@ -1,21 +1,9 @@ #!/usr/bin/env python3 -"""Generate test fixture files for endpoint testing. - -This script automatically connects to a running Balatro instance and generates -.jkr fixture files for testing endpoints. - -Usage: - python generate.py - python generate.py --overwrite # Regenerate all fixtures - -Requirements: -- Balatro must be running with the BalatroBot mod loaded -- Default connection: 127.0.0.1:12346 -""" import argparse import json import socket +from collections import defaultdict from dataclasses import dataclass from pathlib import Path @@ -29,23 +17,11 @@ @dataclass class FixtureSpec: - """Specification for a single fixture.""" - - paths: list[Path] # Output paths (first is primary, rest are copies) - setup: list[tuple[str, dict]] # Sequence of API calls: [(name, arguments), ...] + paths: list[Path] + setup: list[tuple[str, dict]] def api(sock: socket.socket, name: str, arguments: dict) -> dict: - """Send API call to Balatro and return response. - - Args: - sock: Connected socket to Balatro server. - name: API endpoint name. - arguments: API call arguments. - - Returns: - Response dictionary from server. - """ request = {"name": name, "arguments": arguments} message = json.dumps(request) + "\n" sock.sendall(message.encode()) @@ -61,30 +37,64 @@ def api(sock: socket.socket, name: str, arguments: dict) -> dict: def corrupt_file(path: Path) -> None: - """Corrupt a file for error testing.""" path.parent.mkdir(parents=True, exist_ok=True) path.write_bytes(b"CORRUPTED_SAVE_FILE_FOR_TESTING\x00\x01\x02") -def generate_fixture( - sock: socket.socket, - spec: FixtureSpec, - pbar: tqdm, -) -> bool: - """Generate a single fixture from its specification.""" +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["endpoint"], step["arguments"]) 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(sock: socket.socket, spec: FixtureSpec, pbar: tqdm) -> bool: primary_path = spec.paths[0] relative_path = primary_path.relative_to(FIXTURES_DIR) try: - # Execute API call sequence for endpoint, arguments in spec.setup: - api(sock, endpoint, arguments) + response = api(sock, endpoint, arguments) + if "error" in response: + pbar.write(f" Error: {relative_path} - {response['error']}") + return False - # Save fixture primary_path.parent.mkdir(parents=True, exist_ok=True) - api(sock, "save", {"path": str(primary_path)}) + response = api(sock, "save", {"path": str(primary_path)}) + if "error" in response: + pbar.write(f" Error: {relative_path} - {response['error']}") + return False - # Copy to additional paths for dest_path in spec.paths[1:]: dest_path.parent.mkdir(parents=True, exist_ok=True) dest_path.write_bytes(primary_path.read_bytes()) @@ -96,304 +106,24 @@ def generate_fixture( return False -def build_fixtures() -> list[FixtureSpec]: - """Build fixture specifications.""" - return [ - FixtureSpec( - paths=[ - FIXTURES_DIR / "save" / "state-BLIND_SELECT.jkr", - FIXTURES_DIR / "load" / "state-BLIND_SELECT.jkr", - FIXTURES_DIR / "menu" / "state-BLIND_SELECT.jkr", - FIXTURES_DIR / "health" / "state-BLIND_SELECT.jkr", - FIXTURES_DIR / "start" / "state-BLIND_SELECT.jkr", - FIXTURES_DIR / "play" / "state-BLIND_SELECT.jkr", - FIXTURES_DIR / "discard" / "state-BLIND_SELECT.jkr", - FIXTURES_DIR / "next_round" / "state-BLIND_SELECT.jkr", - FIXTURES_DIR / "reroll" / "state-BLIND_SELECT.jkr", - FIXTURES_DIR / "buy" / "state-BLIND_SELECT.jkr", - FIXTURES_DIR - / "skip" - / "state-BLIND_SELECT--blinds.small.status-SELECT.jkr", - FIXTURES_DIR - / "select" - / "state-BLIND_SELECT--blinds.small.status-SELECT.jkr", - FIXTURES_DIR - / "gamestate" - / "state-BLIND_SELECT--round_num-0--deck-RED--stake-WHITE.jkr", - ], - setup=[ - ("menu", {}), - ("start", {"deck": "RED", "stake": "WHITE"}), - ], - ), - FixtureSpec( - paths=[ - FIXTURES_DIR - / "skip" - / "state-BLIND_SELECT--blinds.big.status-SELECT.jkr", - FIXTURES_DIR - / "select" - / "state-BLIND_SELECT--blinds.big.status-SELECT.jkr", - ], - setup=[ - ("menu", {}), - ("start", {"deck": "RED", "stake": "WHITE"}), - ("skip", {}), - ], - ), - FixtureSpec( - paths=[ - FIXTURES_DIR - / "skip" - / "state-BLIND_SELECT--blinds.boss.status-SELECT.jkr", - FIXTURES_DIR - / "select" - / "state-BLIND_SELECT--blinds.boss.status-SELECT.jkr", - ], - setup=[ - ("menu", {}), - ("start", {"deck": "RED", "stake": "WHITE"}), - ("skip", {}), - ("skip", {}), - ], - ), - FixtureSpec( - paths=[ - FIXTURES_DIR / "play" / "state-SELECTING_HAND.jkr", - FIXTURES_DIR / "discard" / "state-SELECTING_HAND.jkr", - FIXTURES_DIR / "set" / "state-SELECTING_HAND.jkr", - ], - setup=[ - ("menu", {}), - ("start", {"deck": "RED", "stake": "WHITE", "seed": "TEST123"}), - ("select", {}), - ], - ), - FixtureSpec( - paths=[ - FIXTURES_DIR / "play" / "state-SELECTING_HAND--round.chips-200.jkr", - ], - setup=[ - ("menu", {}), - ("start", {"deck": "RED", "stake": "WHITE", "seed": "TEST123"}), - ("select", {}), - ("set", {"chips": 200}), - ], - ), - FixtureSpec( - paths=[ - FIXTURES_DIR / "play" / "state-SELECTING_HAND--round.hands_left-1.jkr", - ], - setup=[ - ("menu", {}), - ("start", {"deck": "RED", "stake": "WHITE", "seed": "TEST123"}), - ("select", {}), - ("set", {"hands": 1}), - ], - ), - FixtureSpec( - paths=[ - FIXTURES_DIR - / "discard" - / "state-SELECTING_HAND--round.discards_left-0.jkr", - ], - setup=[ - ("menu", {}), - ("start", {"deck": "RED", "stake": "WHITE", "seed": "TEST123"}), - ("select", {}), - ("set", {"discards": 0}), - ], - ), - FixtureSpec( - paths=[ - FIXTURES_DIR - / "play" - / "state-SELECTING_HAND--ante_num-8--blinds.boss.status-CURRENT--round.chips-1000000.jkr", - ], - setup=[ - ("menu", {}), - ("start", {"deck": "RED", "stake": "WHITE", "seed": "TEST123"}), - ("skip", {}), - ("skip", {}), - ("select", {}), - ("set", {"ante": 8}), - ("set", {"chips": 1_000_000}), - ], - ), - FixtureSpec( - paths=[ - FIXTURES_DIR / "cash_out" / "state-ROUND_EVAL.jkr", - ], - setup=[ - ("menu", {}), - ("start", {"deck": "RED", "stake": "WHITE", "seed": "TEST123"}), - ("select", {}), - ("set", {"chips": 1000}), - ("play", {"cards": [0]}), - ], - ), - FixtureSpec( - paths=[ - FIXTURES_DIR / "set" / "state-SHOP.jkr", - FIXTURES_DIR / "next_round" / "state-SHOP.jkr", - FIXTURES_DIR / "reroll" / "state-SHOP.jkr", - FIXTURES_DIR / "buy" / "state-SHOP--shop.cards[0].set-JOKER.jkr", - FIXTURES_DIR / "buy" / "state-SHOP--shop.cards[1].set-PLANET.jkr", - FIXTURES_DIR / "buy" / "state-SHOP--voucher.cards[0].set-VOUCHER.jkr", - FIXTURES_DIR - / "buy" - / "state-SHOP--packs.cards[0].label-Buffoon+Pack--packs.cards[1].label-Standard+Pack.jkr", - ], - setup=[ - ("menu", {}), - ("start", {"deck": "RED", "stake": "WHITE", "seed": "TEST123"}), - ("select", {}), - ("set", {"chips": 1000}), - ("play", {"cards": [0]}), - ("cash_out", {}), - ], - ), - FixtureSpec( - paths=[ - FIXTURES_DIR / "buy" / "state-SHOP--voucher.count-0.jkr", - ], - setup=[ - ("menu", {}), - ("start", {"deck": "RED", "stake": "WHITE", "seed": "TEST123"}), - ("select", {}), - ("set", {"chips": 1000, "money": 100}), - ("play", {"cards": [0]}), - ("cash_out", {}), - ("buy", {"voucher": 0}), - ], - ), - FixtureSpec( - paths=[ - FIXTURES_DIR / "buy" / "state-SHOP--shop.cards[1].set-TAROT.jkr", - ], - setup=[ - ("menu", {}), - ("start", {"deck": "RED", "stake": "WHITE", "seed": "TEST123"}), - ("select", {}), - ("set", {"chips": 1000, "money": 100}), - ("play", {"cards": [0]}), - ("cash_out", {}), - ("reroll", {}), - ], - ), - FixtureSpec( - paths=[ - FIXTURES_DIR / "buy" / "state-SHOP--shop.count-0.jkr", - ], - setup=[ - ("menu", {}), - ("start", {"deck": "RED", "stake": "WHITE", "seed": "TEST123"}), - ("select", {}), - ("set", {"chips": 1000, "money": 100}), - ("play", {"cards": [0]}), - ("cash_out", {}), - ("buy", {"card": 0}), - ("buy", {"card": 0}), - ], - ), - FixtureSpec( - paths=[ - FIXTURES_DIR - / "buy" - / "state-SHOP--jokers.count-5--shop.cards[0].set-JOKER.jkr", - ], - setup=[ - ("menu", {}), - ("start", {"deck": "RED", "stake": "WHITE", "seed": "TEST123"}), - ("select", {}), - ("set", {"chips": 1000, "money": 1000}), - ("play", {"cards": [0]}), - ("cash_out", {}), - ("buy", {"card": 0}), - ("reroll", {}), - ("buy", {"card": 0}), - ("reroll", {}), - ("buy", {"card": 0}), - ("buy", {"card": 0}), - ("reroll", {}), - ("buy", {"card": 0}), - ], - ), - FixtureSpec( - paths=[ - FIXTURES_DIR - / "buy" - / "state-SHOP--consumables.count-2--shop.cards[1].set-PLANET.jkr", - ], - setup=[ - ("menu", {}), - ("start", {"deck": "RED", "stake": "WHITE", "seed": "TEST123"}), - ("select", {}), - ("set", {"chips": 1000, "money": 100}), - ("play", {"cards": [0]}), - ("cash_out", {}), - ("buy", {"card": 1}), - ("reroll", {}), - ("buy", {"card": 1}), - ("reroll", {}), - ("reroll", {}), - ("reroll", {}), - ], - ), - FixtureSpec( - paths=[ - FIXTURES_DIR / "reroll" / "state-SHOP--money-0.jkr", - FIXTURES_DIR / "buy" / "state-SHOP--money-0.jkr", - ], - setup=[ - ("menu", {}), - ("start", {"deck": "RED", "stake": "WHITE", "seed": "TEST123"}), - ("select", {}), - ("set", {"chips": 1000}), - ("play", {"cards": [0]}), - ("cash_out", {}), - ("set", {"money": 0}), - ], - ), - ] - - def should_generate(spec: FixtureSpec, overwrite: bool = False) -> bool: - """Check if fixture should be generated. - - Args: - spec: Fixture specification to check. - overwrite: If True, generate regardless of existing files. - - Returns: - True if fixture should be generated. - """ if overwrite: return True return not all(path.exists() for path in spec.paths) def main() -> int: - """Main entry point.""" - parser = argparse.ArgumentParser( - description="Generate test fixture files for endpoint testing." - ) - parser.add_argument( - "-o", - "--overwrite", - action="store_true", - help="Regenerate all fixtures, overwriting existing files", - ) + parser = argparse.ArgumentParser() + parser.add_argument("-o", "--overwrite", action="store_true") args = parser.parse_args() - print("BalatroBot Fixture Generator") + print("BalatroBot Fixture Generator v2") print(f"Connecting to {HOST}:{PORT}") - if args.overwrite: - print("Mode: Overwrite all fixtures\n") - else: - print("Mode: Generate missing fixtures only\n") + print(f"Mode: {'Overwrite all' if args.overwrite else 'Generate missing only'}\n") - fixtures = build_fixtures() + json_data = load_fixtures_json() + fixtures = aggregate_fixtures(json_data) + print(f"Loaded {len(fixtures)} unique fixture configurations\n") try: with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: @@ -419,20 +149,14 @@ def main() -> int: skipped += 1 pbar.update(1) - # Go back to menu state api(sock, "menu", {}) - # Generate corrupted fixture corrupted_path = FIXTURES_DIR / "load" / "corrupted.jkr" corrupt_file(corrupted_path) success += 1 print(f"\nSummary: {success} generated, {skipped} skipped, {failed} failed") - - if failed > 0: - return 1 - - return 0 + return 1 if failed > 0 else 0 except ConnectionRefusedError: print(f"Error: Could not connect to Balatro at {HOST}:{PORT}") From 41ebb1476f54758f5fad3408f15af419f4c7ff48 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Wed, 26 Nov 2025 14:42:38 +0100 Subject: [PATCH 128/230] test(lua.endpoints): use fixtures instead of menu endpoint in cash_out tests --- tests/lua/endpoints/test_cash_out.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/lua/endpoints/test_cash_out.py b/tests/lua/endpoints/test_cash_out.py index 2017ed7..e824bf2 100644 --- a/tests/lua/endpoints/test_cash_out.py +++ b/tests/lua/endpoints/test_cash_out.py @@ -21,7 +21,7 @@ def verify_cash_out_response(response: dict[str, Any]) -> None: class TestCashOutEndpoint: """Test basic cash_out endpoint functionality.""" - def test_cash_out_from_round_eval(self, client: socket.socket) -> None: + def test_cash_out_from_ROUND_EVAL(self, client: socket.socket) -> None: """Test cashing out from ROUND_EVAL state.""" save = "state-ROUND_EVAL.jkr" api(client, "load", {"path": str(get_fixture_path("cash_out", save))}) @@ -33,9 +33,10 @@ def test_cash_out_from_round_eval(self, client: socket.socket) -> None: class TestCashOutEndpointStateRequirements: """Test cash_out endpoint state requirements.""" - def test_cash_out_from_MENU(self, client: socket.socket): + def test_cash_out_from_BLIND_SELECT(self, client: socket.socket): """Test that cash_out fails when not in ROUND_EVAL state.""" - response = api(client, "menu", {}) + save = "state-BLIND_SELECT.jkr" + api(client, "load", {"path": str(get_fixture_path("cash_out", save))}) response = api(client, "cash_out", {}) assert_error_response( response, From 21fcd9681192f3414c8b7c8e87039e25f9cd55f8 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Wed, 26 Nov 2025 16:35:55 +0100 Subject: [PATCH 129/230] refactor(lua.utils): remove overwrite argument from generate_fixture Now we generate all fixtures. To force the generation of a specific fixture, set cache to false in the load_fixture function in the corresponding test. --- Makefile | 4 ++-- tests/fixtures/generate.py | 29 ++++++----------------------- 2 files changed, 8 insertions(+), 25 deletions(-) diff --git a/Makefile b/Makefile index 1f822ad..eea09b3 100644 --- a/Makefile +++ b/Makefile @@ -45,14 +45,14 @@ quality: lint typecheck format ## Run all code quality checks fixtures: ## Generate fixtures @echo "$(YELLOW)Starting Balatro...$(RESET)" python balatro.py start --fast --debug - @echo "$(YELLOW)Generating fixtures...$(RESET)" + @echo "$(YELLOW)Generating all fixtures...$(RESET)" python tests/fixtures/generate.py test: ## Run tests head-less @echo "$(YELLOW)Starting Balatro...$(RESET)" python balatro.py start --fast --debug @echo "$(YELLOW)Running tests...$(RESET)" - pytest tests/lua $(if $(PYTEST_MARKER),-m "$(PYTEST_MARKER)") -v + pytest tests/lua $(if $(PYTEST_MARKER),-m "$(PYTEST_MARKER)") -v -s all: lint format typecheck test ## Run all code quality checks and tests @echo "$(GREEN)✓ All checks completed$(RESET)" diff --git a/tests/fixtures/generate.py b/tests/fixtures/generate.py index c349983..18ad830 100644 --- a/tests/fixtures/generate.py +++ b/tests/fixtures/generate.py @@ -106,20 +106,9 @@ def generate_fixture(sock: socket.socket, spec: FixtureSpec, pbar: tqdm) -> bool return False -def should_generate(spec: FixtureSpec, overwrite: bool = False) -> bool: - if overwrite: - return True - return not all(path.exists() for path in spec.paths) - - def main() -> int: - parser = argparse.ArgumentParser() - parser.add_argument("-o", "--overwrite", action="store_true") - args = parser.parse_args() - - print("BalatroBot Fixture Generator v2") - print(f"Connecting to {HOST}:{PORT}") - print(f"Mode: {'Overwrite all' if args.overwrite else 'Generate missing only'}\n") + print("BalatroBot Fixture Generator") + print(f"Connecting to {HOST}:{PORT}\n") json_data = load_fixtures_json() fixtures = aggregate_fixtures(json_data) @@ -131,22 +120,16 @@ def main() -> int: sock.settimeout(10) success = 0 - skipped = 0 failed = 0 with tqdm( total=len(fixtures), desc="Generating fixtures", unit="fixture" ) as pbar: for spec in fixtures: - if should_generate(spec, overwrite=args.overwrite): - if generate_fixture(sock, spec, pbar): - success += 1 - else: - failed += 1 + if generate_fixture(sock, spec, pbar): + success += 1 else: - relative_path = spec.paths[0].relative_to(FIXTURES_DIR) - pbar.write(f" Skipped: {relative_path}") - skipped += 1 + failed += 1 pbar.update(1) api(sock, "menu", {}) @@ -155,7 +138,7 @@ def main() -> int: corrupt_file(corrupted_path) success += 1 - print(f"\nSummary: {success} generated, {skipped} skipped, {failed} failed") + print(f"\nSummary: {success} generated, {failed} failed") return 1 if failed > 0 else 0 except ConnectionRefusedError: From cb9a52b8edc0e6100dfcefcc42d516563021802f Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Wed, 26 Nov 2025 16:48:28 +0100 Subject: [PATCH 130/230] test(lua): add load_fixture helper function --- tests/lua/conftest.py | 76 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 75 insertions(+), 1 deletion(-) diff --git a/tests/lua/conftest.py b/tests/lua/conftest.py index 4f0a46e..8e3535c 100644 --- a/tests/lua/conftest.py +++ b/tests/lua/conftest.py @@ -125,7 +125,7 @@ def get_fixture_path(endpoint: str, fixture_name: str) -> Path: Path to the fixture file in tests/fixtures//. """ fixtures_dir = Path(__file__).parent.parent / "fixtures" - return fixtures_dir / endpoint / fixture_name + return fixtures_dir / endpoint / f"{fixture_name}.jkr" def create_temp_save_path() -> Path: @@ -203,3 +203,77 @@ def assert_health_response(response: dict[str, Any]) -> None: """ assert "status" in response, "Health response must have 'status' field" assert response["status"] == "ok", "Health response 'status' must be 'ok'" + + +def load_fixture( + client: socket.socket, + endpoint: str, + fixture_name: str, + cache: bool = True, +) -> dict[str, Any]: + """Load a fixture file and return the resulting gamestate. + + 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 + + If the fixture file doesn't exist or cache=False, it will be automatically + generated using the setup steps defined in fixtures.json. + + Args: + client: The TCP socket connected to the game. + endpoint: The endpoint directory name (e.g., "buy", "discard"). + fixture_name: Name of the fixture file (e.g., "state-SHOP.jkr"). + cache: If True, use existing fixture file. If False, regenerate (default: True). + + Returns: + The current gamestate after loading the fixture. + + Raises: + AssertionError: If the load operation or generation fails. + KeyError: If fixture definition not found in fixtures.json. + + Example: + gamestate = load_fixture(client, "buy", "state-SHOP.jkr") + response = api(client, "buy", {"card": 0}) + assert response["success"] + """ + 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) + + 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_endpoint = step["endpoint"] + step_arguments = step.get("arguments", {}) + response = api(client, step_endpoint, step_arguments) + + # Check for errors during generation + if "error" in response: + raise AssertionError( + f"Fixture generation failed at step {step_endpoint}: {response['error']}" + ) + + # Save the fixture + fixture_path.parent.mkdir(parents=True, exist_ok=True) + save_response = api(client, "save", {"path": str(fixture_path)}) + assert_success_response(save_response) + + # Load the fixture + load_response = api(client, "load", {"path": str(fixture_path)}) + assert_success_response(load_response) + return api(client, "gamestate", {}) From fde893616228c15861d84ab785d0c63ccd4a55c6 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Wed, 26 Nov 2025 16:48:52 +0100 Subject: [PATCH 131/230] test(lua.endpoints): update tests to use the new load_fixture function --- tests/lua/endpoints/test_buy.py | 192 ++++++++++++++----------- tests/lua/endpoints/test_cash_out.py | 17 ++- tests/lua/endpoints/test_discard.py | 110 +++++++------- tests/lua/endpoints/test_gamestate.py | 10 +- tests/lua/endpoints/test_health.py | 13 +- tests/lua/endpoints/test_load.py | 24 ++-- tests/lua/endpoints/test_menu.py | 6 +- tests/lua/endpoints/test_next_round.py | 10 +- tests/lua/endpoints/test_play.py | 98 +++++++------ tests/lua/endpoints/test_reroll.py | 36 +++-- tests/lua/endpoints/test_save.py | 16 +-- tests/lua/endpoints/test_select.py | 39 ++--- tests/lua/endpoints/test_set.py | 100 +++++++------ tests/lua/endpoints/test_skip.py | 37 +++-- tests/lua/endpoints/test_start.py | 31 ++-- 15 files changed, 383 insertions(+), 356 deletions(-) diff --git a/tests/lua/endpoints/test_buy.py b/tests/lua/endpoints/test_buy.py index 88b6b06..0edaecb 100644 --- a/tests/lua/endpoints/test_buy.py +++ b/tests/lua/endpoints/test_buy.py @@ -4,7 +4,7 @@ import pytest -from tests.lua.conftest import api, assert_error_response, get_fixture_path +from tests.lua.conftest import api, assert_error_response, load_fixture class TestBuyEndpoint: @@ -13,91 +13,99 @@ class TestBuyEndpoint: @pytest.mark.flaky(reruns=2) def test_buy_no_args(self, client: socket.socket) -> None: """Test buy endpoint with no arguments.""" - save = "state-SHOP--shop.cards[0].set-JOKER.jkr" - api(client, "load", {"path": str(get_fixture_path("buy", save))}) - response = api(client, "buy", {}) + 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( - response, - expected_error_code="SCHEMA_INVALID_VALUE", - expected_message_contains="Invalid arguments. You must provide one of: card, voucher, pack", + api(client, "buy", {}), + "SCHEMA_INVALID_VALUE", + "Invalid arguments. You must provide one of: card, voucher, pack", ) @pytest.mark.flaky(reruns=2) def test_buy_multi_args(self, client: socket.socket) -> None: """Test buy endpoint with multiple arguments.""" - save = "state-SHOP--shop.cards[0].set-JOKER.jkr" - api(client, "load", {"path": str(get_fixture_path("buy", save))}) - response = api(client, "buy", {"card": 0, "voucher": 0}) + 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( - response, - expected_error_code="SCHEMA_INVALID_VALUE", - expected_message_contains="Invalid arguments. Cannot provide more than one of: card, voucher, or pack", + api(client, "buy", {"card": 0, "voucher": 0}), + "SCHEMA_INVALID_VALUE", + "Invalid arguments. Cannot provide more than one of: card, voucher, or pack", ) def test_buy_no_card_in_shop_area(self, client: socket.socket) -> None: """Test buy endpoint with no card in shop area.""" - save = "state-SHOP--shop.count-0.jkr" - api(client, "load", {"path": str(get_fixture_path("buy", save))}) - response = api(client, "buy", {"card": 0}) + gamestate = load_fixture(client, "buy", "state-SHOP--shop.count-0") + assert gamestate["state"] == "SHOP" + assert gamestate["shop"]["count"] == 0 assert_error_response( - response, - expected_error_code="SCHEMA_INVALID_VALUE", - expected_message_contains="No jokers/consumables/cards in the shop. Reroll to restock the shop", + api(client, "buy", {"card": 0}), + "SCHEMA_INVALID_VALUE", + "No jokers/consumables/cards in the shop. Reroll to restock the shop", ) def test_buy_invalid_index(self, client: socket.socket) -> None: """Test buy endpoint with invalid card index.""" - save = "state-SHOP--shop.cards[0].set-JOKER.jkr" - api(client, "load", {"path": str(get_fixture_path("buy", save))}) - response = api(client, "buy", {"card": 999}) + 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( - response, - expected_error_code="SCHEMA_INVALID_VALUE", - expected_message_contains="Card index out of range. Index: 999, Available cards: 2", + api(client, "buy", {"card": 999}), + "SCHEMA_INVALID_VALUE", + "Card index out of range. Index: 999, Available cards: 2", ) def test_buy_insufficient_funds(self, client: socket.socket) -> None: """Test buy endpoint when player has insufficient funds.""" - save = "state-SHOP--money-0.jkr" - api(client, "load", {"path": str(get_fixture_path("buy", save))}) - response = api(client, "buy", {"card": 0}) + gamestate = load_fixture(client, "buy", "state-SHOP--money-0") + assert gamestate["state"] == "SHOP" + assert gamestate["money"] == 0 assert_error_response( - response, - expected_error_code="SCHEMA_INVALID_VALUE", - expected_message_contains="Card is not affordable. Cost: 5, Current money: 0", + api(client, "buy", {"card": 0}), + "SCHEMA_INVALID_VALUE", + "Card is not affordable. Cost: 5, Current money: 0", ) def test_buy_joker_slots_full(self, client: socket.socket) -> None: """Test buy endpoint when player has the maximum number of consumables.""" - save = "state-SHOP--jokers.count-5--shop.cards[0].set-JOKER.jkr" - api(client, "load", {"path": str(get_fixture_path("buy", save))}) - response = api(client, "buy", {"card": 0}) + 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( - response, - expected_error_code="SCHEMA_INVALID_VALUE", - expected_message_contains="Cannot purchase joker card, joker slots are full. Current: 5, Limit: 5", + api(client, "buy", {"card": 0}), + "SCHEMA_INVALID_VALUE", + "Cannot purchase joker card, joker slots are full. Current: 5, Limit: 5", ) def test_buy_consumable_slots_full(self, client: socket.socket) -> None: """Test buy endpoint when player has the maximum number of consumables.""" - save = "state-SHOP--consumables.count-2--shop.cards[1].set-PLANET.jkr" - api(client, "load", {"path": str(get_fixture_path("buy", save))}) - response = api(client, "buy", {"card": 1}) + 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( - response, - expected_error_code="SCHEMA_INVALID_VALUE", - expected_message_contains="Cannot purchase consumable card, consumable slots are full. Current: 2, Limit: 2", + api(client, "buy", {"card": 1}), + "SCHEMA_INVALID_VALUE", + "Cannot purchase consumable card, consumable slots are full. Current: 2, Limit: 2", ) def test_buy_vouchers_slot_empty(self, client: socket.socket) -> None: """Test buy endpoint when player has the maximum number of vouchers.""" - save = "state-SHOP--voucher.count-0.jkr" - api(client, "load", {"path": str(get_fixture_path("buy", save))}) - response = api(client, "buy", {"voucher": 0}) + gamestate = load_fixture(client, "buy", "state-SHOP--voucher.count-0") + assert gamestate["state"] == "SHOP" + assert gamestate["vouchers"]["count"] == 0 assert_error_response( - response, - expected_error_code="SCHEMA_INVALID_VALUE", - expected_message_contains="No vouchers to redeem. Defeat boss blind to restock", + api(client, "buy", {"voucher": 0}), + "SCHEMA_INVALID_VALUE", + "No vouchers to redeem. Defeat boss blind to restock", ) @pytest.mark.skip( @@ -105,41 +113,52 @@ def test_buy_vouchers_slot_empty(self, client: socket.socket) -> None: ) def test_buy_packs_slot_empty(self, client: socket.socket) -> None: """Test buy endpoint when player has the maximum number of vouchers.""" - save = "state-SHOP--packs.count-0.jkr" - api(client, "load", {"path": str(get_fixture_path("buy", save))}) - response = api(client, "buy", {"voucher": 0}) + gamestate = load_fixture(client, "buy", "state-SHOP--packs.count-0") + assert gamestate["state"] == "SHOP" + assert gamestate["packs"]["count"] == 0 assert_error_response( - response, - expected_error_code="SCHEMA_INVALID_VALUE", - expected_message_contains="No vouchers to redeem. Defeat boss blind to restock", + api(client, "buy", {"voucher": 0}), + "SCHEMA_INVALID_VALUE", + "No vouchers to redeem. Defeat boss blind to restock", ) def test_buy_joker_success(self, client: socket.socket) -> None: """Test buying a joker card from shop.""" - save = "state-SHOP--shop.cards[0].set-JOKER.jkr" - api(client, "load", {"path": str(get_fixture_path("buy", save))}) + 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}) assert response["jokers"]["cards"][0]["set"] == "JOKER" def test_buy_consumable_success(self, client: socket.socket) -> None: """Test buying a consumable card (Planet/Tarot/Spectral) from shop.""" - save = "state-SHOP--shop.cards[1].set-PLANET.jkr" - api(client, "load", {"path": str(get_fixture_path("buy", save))}) + 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}) assert response["consumables"]["cards"][0]["set"] == "PLANET" def test_buy_voucher_success(self, client: socket.socket) -> None: """Test buying a voucher from shop.""" - save = "state-SHOP--voucher.cards[0].set-VOUCHER.jkr" - api(client, "load", {"path": str(get_fixture_path("buy", save))}) + 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}) assert response["used_vouchers"] is not None assert len(response["used_vouchers"]) > 0 def test_buy_packs_success(self, client: socket.socket) -> None: """Test buying a pack from shop.""" - save = "state-SHOP--packs.cards[0].label-Buffoon+Pack--packs.cards[1].label-Standard+Pack.jkr" - api(client, "load", {"path": str(get_fixture_path("buy", save))}) + 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}) assert response["pack"] is not None assert len(response["pack"]["cards"]) > 0 @@ -150,35 +169,35 @@ class TestBuyEndpointValidation: def test_invalid_card_type_string(self, client: socket.socket) -> None: """Test that buy fails when card parameter is a string instead of integer.""" - save = "state-SHOP--shop.cards[0].set-JOKER.jkr" - api(client, "load", {"path": str(get_fixture_path("buy", save))}) - response = api(client, "buy", {"card": "INVALID_STRING"}) + 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( - response, - expected_error_code="SCHEMA_INVALID_TYPE", - expected_message_contains="Field 'card' must be an integer", + api(client, "buy", {"card": "INVALID_STRING"}), + "SCHEMA_INVALID_TYPE", + "Field 'card' must be an integer", ) def test_invalid_voucher_type_string(self, client: socket.socket) -> None: """Test that buy fails when voucher parameter is a string instead of integer.""" - save = "state-SHOP--shop.cards[0].set-JOKER.jkr" - api(client, "load", {"path": str(get_fixture_path("buy", save))}) - response = api(client, "buy", {"voucher": "INVALID_STRING"}) + 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( - response, - expected_error_code="SCHEMA_INVALID_TYPE", - expected_message_contains="Field 'voucher' must be an integer", + api(client, "buy", {"voucher": "INVALID_STRING"}), + "SCHEMA_INVALID_TYPE", + "Field 'voucher' must be an integer", ) def test_invalid_pack_type_string(self, client: socket.socket) -> None: """Test that buy fails when pack parameter is a string instead of integer.""" - save = "state-SHOP--shop.cards[0].set-JOKER.jkr" - api(client, "load", {"path": str(get_fixture_path("buy", save))}) - response = api(client, "buy", {"pack": "INVALID_STRING"}) + 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( - response, - expected_error_code="SCHEMA_INVALID_TYPE", - expected_message_contains="Field 'pack' must be an integer", + api(client, "buy", {"pack": "INVALID_STRING"}), + "SCHEMA_INVALID_TYPE", + "Field 'pack' must be an integer", ) @@ -187,11 +206,10 @@ class TestBuyEndpointStateRequirements: def test_buy_from_BLIND_SELECT(self, client: socket.socket) -> None: """Test that buy fails when not in SHOP state.""" - save = "state-BLIND_SELECT.jkr" - api(client, "load", {"path": str(get_fixture_path("buy", save))}) - response = api(client, "buy", {"card": 0}) + gamestate = load_fixture(client, "buy", "state-BLIND_SELECT") + assert gamestate["state"] == "BLIND_SELECT" assert_error_response( - response, - expected_error_code="STATE_INVALID_STATE", - expected_message_contains="Endpoint 'buy' requires one of these states:", + api(client, "buy", {"card": 0}), + "STATE_INVALID_STATE", + "Endpoint 'buy' requires one of these states:", ) diff --git a/tests/lua/endpoints/test_cash_out.py b/tests/lua/endpoints/test_cash_out.py index e824bf2..0194bce 100644 --- a/tests/lua/endpoints/test_cash_out.py +++ b/tests/lua/endpoints/test_cash_out.py @@ -3,7 +3,7 @@ import socket from typing import Any -from tests.lua.conftest import api, assert_error_response, get_fixture_path +from tests.lua.conftest import api, assert_error_response, load_fixture def verify_cash_out_response(response: dict[str, Any]) -> None: @@ -23,8 +23,8 @@ class TestCashOutEndpoint: def test_cash_out_from_ROUND_EVAL(self, client: socket.socket) -> None: """Test cashing out from ROUND_EVAL state.""" - save = "state-ROUND_EVAL.jkr" - api(client, "load", {"path": str(get_fixture_path("cash_out", save))}) + gamestate = load_fixture(client, "cash_out", "state-ROUND_EVAL") + assert gamestate["state"] == "ROUND_EVAL" response = api(client, "cash_out", {}) verify_cash_out_response(response) assert response["state"] == "SHOP" @@ -35,11 +35,10 @@ class TestCashOutEndpointStateRequirements: def test_cash_out_from_BLIND_SELECT(self, client: socket.socket): """Test that cash_out fails when not in ROUND_EVAL state.""" - save = "state-BLIND_SELECT.jkr" - api(client, "load", {"path": str(get_fixture_path("cash_out", save))}) - response = api(client, "cash_out", {}) + gamestate = load_fixture(client, "cash_out", "state-BLIND_SELECT") + assert gamestate["state"] == "BLIND_SELECT" assert_error_response( - response, - expected_error_code="STATE_INVALID_STATE", - expected_message_contains="Endpoint 'cash_out' requires one of these states:", + api(client, "cash_out", {}), + "STATE_INVALID_STATE", + "Endpoint 'cash_out' requires one of these states:", ) diff --git a/tests/lua/endpoints/test_discard.py b/tests/lua/endpoints/test_discard.py index 84dfc04..3879d8a 100644 --- a/tests/lua/endpoints/test_discard.py +++ b/tests/lua/endpoints/test_discard.py @@ -2,7 +2,7 @@ import socket -from tests.lua.conftest import api, assert_error_response, get_fixture_path +from tests.lua.conftest import api, assert_error_response, load_fixture class TestDiscardEndpoint: @@ -10,65 +10,68 @@ class TestDiscardEndpoint: def test_discard_zero_cards(self, client: socket.socket) -> None: """Test discard endpoint with empty cards array.""" - save = "state-SELECTING_HAND.jkr" - api(client, "load", {"path": str(get_fixture_path("discard", save))}) - response = api(client, "discard", {"cards": []}) + gamestate = load_fixture(client, "discard", "state-SELECTING_HAND") + assert gamestate["state"] == "SELECTING_HAND" assert_error_response( - response, - expected_error_code="SCHEMA_INVALID_VALUE", - expected_message_contains="Must provide at least one card to discard", + api(client, "discard", {"cards": []}), + "SCHEMA_INVALID_VALUE", + "Must provide at least one card to discard", ) def test_discard_too_many_cards(self, client: socket.socket) -> None: """Test discard endpoint with more cards than limit.""" - save = "state-SELECTING_HAND.jkr" - api(client, "load", {"path": str(get_fixture_path("discard", save))}) - response = api(client, "discard", {"cards": [0, 1, 2, 3, 4, 5]}) + gamestate = load_fixture(client, "discard", "state-SELECTING_HAND") + assert gamestate["state"] == "SELECTING_HAND" assert_error_response( - response, - expected_error_code="SCHEMA_INVALID_VALUE", - expected_message_contains="You can only discard 5 cards", + api(client, "discard", {"cards": [0, 1, 2, 3, 4, 5]}), + "SCHEMA_INVALID_VALUE", + "You can only discard 5 cards", ) def test_discard_out_of_range_cards(self, client: socket.socket) -> None: """Test discard endpoint with invalid card index.""" - save = "state-SELECTING_HAND.jkr" - api(client, "load", {"path": str(get_fixture_path("discard", save))}) - response = api(client, "discard", {"cards": [999]}) + gamestate = load_fixture(client, "discard", "state-SELECTING_HAND") + assert gamestate["state"] == "SELECTING_HAND" assert_error_response( - response, - expected_error_code="SCHEMA_INVALID_VALUE", - expected_message_contains="Invalid card index: 999", + api(client, "discard", {"cards": [999]}), + "SCHEMA_INVALID_VALUE", + "Invalid card index: 999", ) def test_discard_no_discards_left(self, client: socket.socket) -> None: """Test discard endpoint when no discards remain.""" - save = "state-SELECTING_HAND--round.discards_left-0.jkr" - api(client, "load", {"path": str(get_fixture_path("discard", save))}) - response = api(client, "discard", {"cards": [0]}) + 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( - response, - expected_error_code="SCHEMA_INVALID_VALUE", - expected_message_contains="No discards left", + api(client, "discard", {"cards": [0]}), + "SCHEMA_INVALID_VALUE", + "No discards left", ) def test_discard_valid_single_card(self, client: socket.socket) -> None: """Test discard endpoint with valid single card.""" - save = "state-SELECTING_HAND.jkr" - api(client, "load", {"path": str(get_fixture_path("discard", save))}) - before = api(client, "gamestate", {}) - after = api(client, "discard", {"cards": [0]}) - assert after["state"] == "SELECTING_HAND" - assert after["round"]["discards_left"] == before["round"]["discards_left"] - 1 + gamestate = load_fixture(client, "discard", "state-SELECTING_HAND") + assert gamestate["state"] == "SELECTING_HAND" + response = api(client, "discard", {"cards": [0]}) + assert response["state"] == "SELECTING_HAND" + assert ( + response["round"]["discards_left"] + == gamestate["round"]["discards_left"] - 1 + ) def test_discard_valid_multiple_cards(self, client: socket.socket) -> None: """Test discard endpoint with valid multiple cards.""" - save = "state-SELECTING_HAND.jkr" - api(client, "load", {"path": str(get_fixture_path("discard", save))}) - before = api(client, "gamestate", {}) - after = api(client, "discard", {"cards": [1, 2, 3]}) - assert after["state"] == "SELECTING_HAND" - assert after["round"]["discards_left"] == before["round"]["discards_left"] - 1 + gamestate = load_fixture(client, "discard", "state-SELECTING_HAND") + assert gamestate["state"] == "SELECTING_HAND" + response = api(client, "discard", {"cards": [1, 2, 3]}) + assert response["state"] == "SELECTING_HAND" + assert ( + response["round"]["discards_left"] + == gamestate["round"]["discards_left"] - 1 + ) class TestDiscardEndpointValidation: @@ -76,24 +79,22 @@ class TestDiscardEndpointValidation: def test_missing_cards_parameter(self, client: socket.socket): """Test that discard fails when cards parameter is missing.""" - save = "state-SELECTING_HAND.jkr" - api(client, "load", {"path": str(get_fixture_path("discard", save))}) - response = api(client, "discard", {}) + gamestate = load_fixture(client, "discard", "state-SELECTING_HAND") + assert gamestate["state"] == "SELECTING_HAND" assert_error_response( - response, - expected_error_code="SCHEMA_MISSING_REQUIRED", - expected_message_contains="Missing required field 'cards'", + api(client, "discard", {}), + "SCHEMA_MISSING_REQUIRED", + "Missing required field 'cards'", ) def test_invalid_cards_type(self, client: socket.socket): """Test that discard fails when cards parameter is not an array.""" - save = "state-SELECTING_HAND.jkr" - api(client, "load", {"path": str(get_fixture_path("discard", save))}) - response = api(client, "discard", {"cards": "INVALID_CARDS"}) + gamestate = load_fixture(client, "discard", "state-SELECTING_HAND") + assert gamestate["state"] == "SELECTING_HAND" assert_error_response( - response, - expected_error_code="SCHEMA_INVALID_TYPE", - expected_message_contains="Field 'cards' must be an array", + api(client, "discard", {"cards": "INVALID_CARDS"}), + "SCHEMA_INVALID_TYPE", + "Field 'cards' must be an array", ) @@ -102,11 +103,10 @@ class TestDiscardEndpointStateRequirements: def test_discard_from_BLIND_SELECT(self, client: socket.socket): """Test that discard fails when not in SELECTING_HAND state.""" - save = "state-BLIND_SELECT.jkr" - api(client, "load", {"path": str(get_fixture_path("discard", save))}) - response = api(client, "discard", {"cards": [0]}) + gamestate = load_fixture(client, "discard", "state-BLIND_SELECT") + assert gamestate["state"] == "BLIND_SELECT" assert_error_response( - response, - expected_error_code="STATE_INVALID_STATE", - expected_message_contains="Endpoint 'discard' requires one of these states:", + api(client, "discard", {"cards": [0]}), + "STATE_INVALID_STATE", + "Endpoint 'discard' requires one of these states:", ) diff --git a/tests/lua/endpoints/test_gamestate.py b/tests/lua/endpoints/test_gamestate.py index a8e7dfa..9b1d775 100644 --- a/tests/lua/endpoints/test_gamestate.py +++ b/tests/lua/endpoints/test_gamestate.py @@ -3,7 +3,7 @@ import socket from typing import Any -from tests.lua.conftest import api, get_fixture_path +from tests.lua.conftest import api, load_fixture def verify_base_gamestate_response(response: dict[str, Any]) -> None: @@ -41,8 +41,12 @@ def test_gamestate_from_MENU(self, client: socket.socket) -> None: def test_gamestate_from_BLIND_SELECT(self, client: socket.socket) -> None: """Test that gamestate from BLIND_SELECT state is valid.""" - save = "state-BLIND_SELECT--round_num-0--deck-RED--stake-WHITE.jkr" - api(client, "load", {"path": str(get_fixture_path("gamestate", save))}) + 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", {}) verify_base_gamestate_response(response) assert response["state"] == "BLIND_SELECT" diff --git a/tests/lua/endpoints/test_health.py b/tests/lua/endpoints/test_health.py index 917cc43..70ba4a4 100644 --- a/tests/lua/endpoints/test_health.py +++ b/tests/lua/endpoints/test_health.py @@ -8,7 +8,7 @@ import socket from typing import Any -from tests.lua.conftest import api, get_fixture_path +from tests.lua.conftest import api, load_fixture def assert_health_response(response: dict[str, Any]) -> None: @@ -23,12 +23,11 @@ def test_health_from_MENU(self, client: socket.socket) -> None: """Test that health check returns status ok.""" response = api(client, "menu", {}) assert response["state"] == "MENU" - response = api(client, "health", {}) - assert_health_response(response) + assert_health_response(api(client, "health", {})) def test_health_from_BLIND_SELECT(self, client: socket.socket) -> None: """Test that health check returns status ok.""" - save = "state-BLIND_SELECT.jkr" - api(client, "load", {"path": str(get_fixture_path("health", save))}) - response = api(client, "health", {}) - assert_health_response(response) + 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 index 9c902df..4d8de79 100644 --- a/tests/lua/endpoints/test_load.py +++ b/tests/lua/endpoints/test_load.py @@ -16,22 +16,20 @@ class TestLoadEndpoint: def test_load_from_fixture(self, client: socket.socket) -> None: """Test that load succeeds with a valid fixture file.""" - fixture_path = get_fixture_path("load", "state-BLIND_SELECT.jkr") - + fixture_path = get_fixture_path("load", "state-BLIND_SELECT") response = api(client, "load", {"path": str(fixture_path)}) - assert_success_response(response) assert response["path"] == str(fixture_path) def test_load_save_roundtrip(self, client: socket.socket, tmp_path: Path) -> None: """Test that a loaded fixture can be saved and loaded again.""" # Load fixture - fixture_path = get_fixture_path("load", "state-BLIND_SELECT.jkr") + fixture_path = get_fixture_path("load", "state-BLIND_SELECT") load_response = api(client, "load", {"path": str(fixture_path)}) assert_success_response(load_response) # Save to temp path - temp_file = tmp_path / "save.jkr" + temp_file = tmp_path / "save" save_response = api(client, "save", {"path": str(temp_file)}) assert_success_response(save_response) assert temp_file.exists() @@ -46,20 +44,16 @@ class TestLoadValidation: def test_missing_path_parameter(self, client: socket.socket) -> None: """Test that load fails when path parameter is missing.""" - response = api(client, "load", {}) - assert_error_response( - response, - expected_error_code="SCHEMA_MISSING_REQUIRED", - expected_message_contains="Missing required field 'path'", + api(client, "load", {}), + "SCHEMA_MISSING_REQUIRED", + "Missing required field 'path'", ) def test_invalid_path_type(self, client: socket.socket) -> None: """Test that load fails when path is not a string.""" - response = api(client, "load", {"path": 123}) - assert_error_response( - response, - expected_error_code="SCHEMA_INVALID_TYPE", - expected_message_contains="Field 'path' must be of type string", + api(client, "load", {"path": 123}), + "SCHEMA_INVALID_TYPE", + "Field 'path' must be of type string", ) diff --git a/tests/lua/endpoints/test_menu.py b/tests/lua/endpoints/test_menu.py index 4b8e936..fb0a33c 100644 --- a/tests/lua/endpoints/test_menu.py +++ b/tests/lua/endpoints/test_menu.py @@ -3,7 +3,7 @@ import socket from typing import Any -from tests.lua.conftest import api, get_fixture_path +from tests.lua.conftest import api, load_fixture def verify_base_menu_response(response: dict[str, Any]) -> None: @@ -25,7 +25,7 @@ def test_menu_from_MENU(self, client: socket.socket) -> None: def test_menu_from_BLIND_SELECT(self, client: socket.socket) -> None: """Test that menu endpoint returns state as MENU.""" - save = "state-BLIND_SELECT--round_num-0--deck-RED--stake-WHITE.jkr" - api(client, "load", {"path": str(get_fixture_path("menu", save))}) + gamestate = load_fixture(client, "menu", "state-BLIND_SELECT") + assert gamestate["state"] == "BLIND_SELECT" response = api(client, "menu", {}) verify_base_menu_response(response) diff --git a/tests/lua/endpoints/test_next_round.py b/tests/lua/endpoints/test_next_round.py index 1df1fe6..16f72c2 100644 --- a/tests/lua/endpoints/test_next_round.py +++ b/tests/lua/endpoints/test_next_round.py @@ -3,7 +3,7 @@ import socket from typing import Any -from tests.lua.conftest import api, assert_error_response, get_fixture_path +from tests.lua.conftest import api, assert_error_response, load_fixture def verify_next_round_response(response: dict[str, Any]) -> None: @@ -23,8 +23,8 @@ class TestNextRoundEndpoint: def test_next_round_from_shop(self, client: socket.socket) -> None: """Test advancing to next round from SHOP state.""" - save = "state-SHOP.jkr" - api(client, "load", {"path": str(get_fixture_path("next_round", save))}) + gamestate = load_fixture(client, "next_round", "state-SHOP") + assert gamestate["state"] == "SHOP" response = api(client, "next_round", {}) verify_next_round_response(response) @@ -34,8 +34,8 @@ class TestNextRoundEndpointStateRequirements: def test_next_round_from_MENU(self, client: socket.socket): """Test that next_round fails when not in SHOP state.""" - save = "state-BLIND_SELECT.jkr" - api(client, "load", {"path": str(get_fixture_path("next_round", save))}) + gamestate = load_fixture(client, "next_round", "state-BLIND_SELECT") + assert gamestate["state"] == "BLIND_SELECT" response = api(client, "next_round", {}) assert_error_response( response, diff --git a/tests/lua/endpoints/test_play.py b/tests/lua/endpoints/test_play.py index 43c6220..322b8a8 100644 --- a/tests/lua/endpoints/test_play.py +++ b/tests/lua/endpoints/test_play.py @@ -2,7 +2,7 @@ import socket -from tests.lua.conftest import api, assert_error_response, get_fixture_path +from tests.lua.conftest import api, assert_error_response, load_fixture class TestPlayEndpoint: @@ -10,41 +10,38 @@ class TestPlayEndpoint: def test_play_zero_cards(self, client: socket.socket) -> None: """Test play endpoint from BLIND_SELECT state.""" - save = "state-SELECTING_HAND.jkr" - api(client, "load", {"path": str(get_fixture_path("play", save))}) - response = api(client, "play", {"cards": []}) + gamestate = load_fixture(client, "play", "state-SELECTING_HAND") + assert gamestate["state"] == "SELECTING_HAND" assert_error_response( - response, - expected_error_code="SCHEMA_INVALID_VALUE", - expected_message_contains="Must provide at least one card to play", + api(client, "play", {"cards": []}), + "SCHEMA_INVALID_VALUE", + "Must provide at least one card to play", ) def test_play_six_cards(self, client: socket.socket) -> None: """Test play endpoint from BLIND_SELECT state.""" - save = "state-SELECTING_HAND.jkr" - api(client, "load", {"path": str(get_fixture_path("play", save))}) - response = api(client, "play", {"cards": [0, 1, 2, 3, 4, 5]}) + gamestate = load_fixture(client, "play", "state-SELECTING_HAND") + assert gamestate["state"] == "SELECTING_HAND" assert_error_response( - response, - expected_error_code="SCHEMA_INVALID_VALUE", - expected_message_contains="You can only play 5 cards", + api(client, "play", {"cards": [0, 1, 2, 3, 4, 5]}), + "SCHEMA_INVALID_VALUE", + "You can only play 5 cards", ) def test_play_out_of_range_cards(self, client: socket.socket) -> None: """Test play endpoint from BLIND_SELECT state.""" - save = "state-SELECTING_HAND.jkr" - api(client, "load", {"path": str(get_fixture_path("play", save))}) - response = api(client, "play", {"cards": [999]}) + gamestate = load_fixture(client, "play", "state-SELECTING_HAND") + assert gamestate["state"] == "SELECTING_HAND" assert_error_response( - response, - expected_error_code="SCHEMA_INVALID_VALUE", - expected_message_contains="Invalid card index: 999", + api(client, "play", {"cards": [999]}), + "SCHEMA_INVALID_VALUE", + "Invalid card index: 999", ) def test_play_valid_cards_and_round_active(self, client: socket.socket) -> None: """Test play endpoint from BLIND_SELECT state.""" - save = "state-SELECTING_HAND.jkr" - api(client, "load", {"path": str(get_fixture_path("play", save))}) + gamestate = load_fixture(client, "play", "state-SELECTING_HAND") + assert gamestate["state"] == "SELECTING_HAND" response = api(client, "play", {"cards": [0, 3, 4, 5, 6]}) assert response["state"] == "SELECTING_HAND" assert response["hands"]["Flush"]["played_this_round"] == 1 @@ -52,23 +49,35 @@ def test_play_valid_cards_and_round_active(self, client: socket.socket) -> None: def test_play_valid_cards_and_round_won(self, client: socket.socket) -> None: """Test play endpoint from BLIND_SELECT state.""" - save = "state-SELECTING_HAND--round.chips-200.jkr" - api(client, "load", {"path": str(get_fixture_path("play", save))}) + 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 response["state"] == "ROUND_EVAL" def test_play_valid_cards_and_game_won(self, client: socket.socket) -> None: """Test play endpoint from BLIND_SELECT state.""" - save = "state-SELECTING_HAND--ante_num-8--blinds.boss.status-CURRENT--round.chips-1000000.jkr" - api(client, "load", {"path": str(get_fixture_path("play", save))}) + 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 response["won"] is True def test_play_valid_cards_and_game_over(self, client: socket.socket) -> None: """Test play endpoint from BLIND_SELECT state.""" - save = "state-SELECTING_HAND--round.hands_left-1.jkr" - api(client, "load", {"path": str(get_fixture_path("play", save))}) - # SMODS.calculate_context() in end_round() can take longer for game_over + 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 response["state"] == "GAME_OVER" @@ -78,24 +87,22 @@ class TestPlayEndpointValidation: def test_missing_cards_parameter(self, client: socket.socket): """Test that play fails when cards parameter is missing.""" - save = "state-SELECTING_HAND.jkr" - api(client, "load", {"path": str(get_fixture_path("play", save))}) - response = api(client, "play", {}) + gamestate = load_fixture(client, "play", "state-SELECTING_HAND") + assert gamestate["state"] == "SELECTING_HAND" assert_error_response( - response, - expected_error_code="SCHEMA_MISSING_REQUIRED", - expected_message_contains="Missing required field 'cards'", + api(client, "play", {}), + "SCHEMA_MISSING_REQUIRED", + "Missing required field 'cards'", ) def test_invalid_cards_type(self, client: socket.socket): """Test that play fails when cards parameter is not an array.""" - save = "state-SELECTING_HAND.jkr" - api(client, "load", {"path": str(get_fixture_path("play", save))}) - response = api(client, "play", {"cards": "INVALID_CARDS"}) + gamestate = load_fixture(client, "play", "state-SELECTING_HAND") + assert gamestate["state"] == "SELECTING_HAND" assert_error_response( - response, - expected_error_code="SCHEMA_INVALID_TYPE", - expected_message_contains="Field 'cards' must be an array", + api(client, "play", {"cards": "INVALID_CARDS"}), + "SCHEMA_INVALID_TYPE", + "Field 'cards' must be an array", ) @@ -104,11 +111,10 @@ class TestPlayEndpointStateRequirements: def test_play_from_BLIND_SELECT(self, client: socket.socket): """Test that play fails when not in SELECTING_HAND state.""" - save = "state-BLIND_SELECT.jkr" - api(client, "load", {"path": str(get_fixture_path("play", save))}) - response = api(client, "play", {"cards": [0]}) + gamestate = load_fixture(client, "play", "state-BLIND_SELECT") + assert gamestate["state"] == "BLIND_SELECT" assert_error_response( - response, - expected_error_code="STATE_INVALID_STATE", - expected_message_contains="Endpoint 'play' requires one of these states:", + api(client, "play", {"cards": [0]}), + "STATE_INVALID_STATE", + "Endpoint 'play' requires one of these states:", ) diff --git a/tests/lua/endpoints/test_reroll.py b/tests/lua/endpoints/test_reroll.py index cdaf7ef..2aef5d2 100644 --- a/tests/lua/endpoints/test_reroll.py +++ b/tests/lua/endpoints/test_reroll.py @@ -2,7 +2,7 @@ import socket -from tests.lua.conftest import api, assert_error_response, get_fixture_path +from tests.lua.conftest import api, assert_error_response, load_fixture class TestRerollEndpoint: @@ -10,23 +10,22 @@ class TestRerollEndpoint: def test_reroll_from_shop(self, client: socket.socket) -> None: """Test rerolling shop from SHOP state.""" - save = "state-SHOP.jkr" - api(client, "load", {"path": str(get_fixture_path("reroll", save))}) - after = api(client, "gamestate", {}) - before = api(client, "reroll", {}) + gamestate = load_fixture(client, "reroll", "state-SHOP") + assert gamestate["state"] == "SHOP" + after = api(client, "reroll", {}) + assert gamestate["state"] == "SHOP" assert after["state"] == "SHOP" - assert before["state"] == "SHOP" - assert after["shop"] != before["shop"] + assert gamestate["shop"] != after["shop"] def test_reroll_insufficient_funds(self, client: socket.socket) -> None: """Test reroll endpoint when player has insufficient funds.""" - save = "state-SHOP--money-0.jkr" - api(client, "load", {"path": str(get_fixture_path("reroll", save))}) - response = api(client, "reroll", {}) + gamestate = load_fixture(client, "reroll", "state-SHOP--money-0") + assert gamestate["state"] == "SHOP" + assert gamestate["money"] == 0 assert_error_response( - response, - expected_error_code="GAME_INVALID_STATE", - expected_message_contains="Not enough dollars to reroll", + api(client, "reroll", {}), + "GAME_INVALID_STATE", + "Not enough dollars to reroll", ) @@ -35,11 +34,10 @@ class TestRerollEndpointStateRequirements: def test_reroll_from_BLIND_SELECT(self, client: socket.socket): """Test that reroll fails when not in SHOP state.""" - save = "state-BLIND_SELECT.jkr" - api(client, "load", {"path": str(get_fixture_path("reroll", save))}) - response = api(client, "reroll", {}) + gamestate = load_fixture(client, "reroll", "state-BLIND_SELECT") + assert gamestate["state"] == "BLIND_SELECT" assert_error_response( - response, - expected_error_code="STATE_INVALID_STATE", - expected_message_contains="Endpoint 'reroll' requires one of these states:", + api(client, "reroll", {}), + "STATE_INVALID_STATE", + "Endpoint 'reroll' requires one of these states:", ) diff --git a/tests/lua/endpoints/test_save.py b/tests/lua/endpoints/test_save.py index fea36c8..dbfca20 100644 --- a/tests/lua/endpoints/test_save.py +++ b/tests/lua/endpoints/test_save.py @@ -7,7 +7,7 @@ api, assert_error_response, assert_success_response, - get_fixture_path, + load_fixture, ) @@ -18,9 +18,9 @@ def test_save_from_BLIND_SELECT( self, client: socket.socket, tmp_path: Path ) -> None: """Test that save succeeds from BLIND_SELECT state.""" - save = "state-BLIND_SELECT.jkr" - api(client, "load", {"path": str(get_fixture_path("save", save))}) - temp_file = tmp_path / "save.jkr" + 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_success_response(response) assert response["path"] == str(temp_file) @@ -31,9 +31,9 @@ def test_save_creates_valid_file( self, client: socket.socket, tmp_path: Path ) -> None: """Test that saved file can be loaded back successfully.""" - save = "state-BLIND_SELECT.jkr" - api(client, "load", {"path": str(get_fixture_path("save", save))}) - temp_file = tmp_path / "save.jkr" + 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_success_response(save_response) load_response = api(client, "load", {"path": str(temp_file)}) @@ -68,7 +68,7 @@ class TestSaveStateRequirements: def test_save_from_MENU(self, client: socket.socket, tmp_path: Path) -> None: """Test that save fails when not in an active run.""" api(client, "menu", {}) - temp_file = tmp_path / "save.jkr" + temp_file = tmp_path / "save" response = api(client, "save", {"path": str(temp_file)}) assert_error_response( response, diff --git a/tests/lua/endpoints/test_select.py b/tests/lua/endpoints/test_select.py index 369fbf3..57b8f4d 100644 --- a/tests/lua/endpoints/test_select.py +++ b/tests/lua/endpoints/test_select.py @@ -3,7 +3,7 @@ import socket from typing import Any -from tests.lua.conftest import api, assert_error_response, get_fixture_path +from tests.lua.conftest import api, assert_error_response, load_fixture def verify_select_response(response: dict[str, Any]) -> None: @@ -17,36 +17,42 @@ def verify_select_response(response: dict[str, Any]) -> None: assert "hand" in response assert isinstance(response["hand"], dict) + # Verify we transitioned to SELECTING_HAND state + assert response["state"] == "SELECTING_HAND" + class TestSelectEndpoint: """Test basic select endpoint functionality.""" def test_select_small_blind(self, client: socket.socket) -> None: """Test selecting Small blind in BLIND_SELECT state.""" - save = "state-BLIND_SELECT--blinds.small.status-SELECT.jkr" - api(client, "load", {"path": str(get_fixture_path("select", save))}) + 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", {}) verify_select_response(response) - # Verify we transitioned to SELECTING_HAND state - assert response["state"] == "SELECTING_HAND" def test_select_big_blind(self, client: socket.socket) -> None: """Test selecting Big blind in BLIND_SELECT state.""" - save = "state-BLIND_SELECT--blinds.big.status-SELECT.jkr" - api(client, "load", {"path": str(get_fixture_path("select", save))}) + 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", {}) verify_select_response(response) - # Verify we transitioned to SELECTING_HAND state - assert response["state"] == "SELECTING_HAND" def test_select_boss_blind(self, client: socket.socket) -> None: """Test selecting Boss blind in BLIND_SELECT state.""" - save = "state-BLIND_SELECT--blinds.boss.status-SELECT.jkr" - api(client, "load", {"path": str(get_fixture_path("select", save))}) + 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", {}) verify_select_response(response) - # Verify we transitioned to SELECTING_HAND state - assert response["state"] == "SELECTING_HAND" class TestSelectEndpointStateRequirements: @@ -56,9 +62,8 @@ def test_select_from_MENU(self, client: socket.socket): """Test that select fails when not in BLIND_SELECT state.""" response = api(client, "menu", {}) assert response["state"] == "MENU" - response = api(client, "select", {}) assert_error_response( - response, - expected_error_code="STATE_INVALID_STATE", - expected_message_contains="Endpoint 'select' requires one of these states:", + api(client, "select", {}), + "STATE_INVALID_STATE", + "Endpoint 'select' requires one of these states:", ) diff --git a/tests/lua/endpoints/test_set.py b/tests/lua/endpoints/test_set.py index ed31b0e..bdc8793 100644 --- a/tests/lua/endpoints/test_set.py +++ b/tests/lua/endpoints/test_set.py @@ -2,7 +2,7 @@ import socket -from tests.lua.conftest import api, assert_error_response, get_fixture_path +from tests.lua.conftest import api, assert_error_response, load_fixture class TestSetEndpoint: @@ -20,8 +20,8 @@ def test_set_game_not_in_run(self, client: socket.socket) -> None: def test_set_no_fields(self, client: socket.socket) -> None: """Test that set fails when no fields are provided.""" - save = "state-SELECTING_HAND.jkr" - api(client, "load", {"path": str(get_fixture_path("set", save))}) + gamestate = load_fixture(client, "set", "state-SELECTING_HAND") + assert gamestate["state"] == "SELECTING_HAND" response = api(client, "set", {}) assert_error_response( response, @@ -31,8 +31,8 @@ def test_set_no_fields(self, client: socket.socket) -> None: def test_set_negative_money(self, client: socket.socket) -> None: """Test that set fails when money is negative.""" - save = "state-SELECTING_HAND.jkr" - api(client, "load", {"path": str(get_fixture_path("set", save))}) + gamestate = load_fixture(client, "set", "state-SELECTING_HAND") + assert gamestate["state"] == "SELECTING_HAND" response = api(client, "set", {"money": -100}) assert_error_response( response, @@ -42,15 +42,15 @@ def test_set_negative_money(self, client: socket.socket) -> None: def test_set_money(self, client: socket.socket) -> None: """Test that set succeeds when money is positive.""" - save = "state-SELECTING_HAND.jkr" - api(client, "load", {"path": str(get_fixture_path("set", save))}) + gamestate = load_fixture(client, "set", "state-SELECTING_HAND") + assert gamestate["state"] == "SELECTING_HAND" response = api(client, "set", {"money": 100}) assert response["money"] == 100 def test_set_negative_chips(self, client: socket.socket) -> None: """Test that set fails when chips is negative.""" - save = "state-SELECTING_HAND.jkr" - api(client, "load", {"path": str(get_fixture_path("set", save))}) + gamestate = load_fixture(client, "set", "state-SELECTING_HAND") + assert gamestate["state"] == "SELECTING_HAND" response = api(client, "set", {"chips": -100}) assert_error_response( response, @@ -60,15 +60,15 @@ def test_set_negative_chips(self, client: socket.socket) -> None: def test_set_chips(self, client: socket.socket) -> None: """Test that set succeeds when chips is positive.""" - save = "state-SELECTING_HAND.jkr" - api(client, "load", {"path": str(get_fixture_path("set", save))}) + gamestate = load_fixture(client, "set", "state-SELECTING_HAND") + assert gamestate["state"] == "SELECTING_HAND" response = api(client, "set", {"chips": 100}) assert response["round"]["chips"] == 100 def test_set_negative_ante(self, client: socket.socket) -> None: """Test that set fails when ante is negative.""" - save = "state-SELECTING_HAND.jkr" - api(client, "load", {"path": str(get_fixture_path("set", save))}) + gamestate = load_fixture(client, "set", "state-SELECTING_HAND") + assert gamestate["state"] == "SELECTING_HAND" response = api(client, "set", {"ante": -8}) assert_error_response( response, @@ -78,15 +78,15 @@ def test_set_negative_ante(self, client: socket.socket) -> None: def test_set_ante(self, client: socket.socket) -> None: """Test that set succeeds when ante is positive.""" - save = "state-SELECTING_HAND.jkr" - api(client, "load", {"path": str(get_fixture_path("set", save))}) + gamestate = load_fixture(client, "set", "state-SELECTING_HAND") + assert gamestate["state"] == "SELECTING_HAND" response = api(client, "set", {"ante": 8}) assert response["ante_num"] == 8 def test_set_negative_round(self, client: socket.socket) -> None: """Test that set fails when round is negative.""" - save = "state-SELECTING_HAND.jkr" - api(client, "load", {"path": str(get_fixture_path("set", save))}) + gamestate = load_fixture(client, "set", "state-SELECTING_HAND") + assert gamestate["state"] == "SELECTING_HAND" response = api(client, "set", {"round": -5}) assert_error_response( response, @@ -96,15 +96,15 @@ def test_set_negative_round(self, client: socket.socket) -> None: def test_set_round(self, client: socket.socket) -> None: """Test that set succeeds when round is positive.""" - save = "state-SELECTING_HAND.jkr" - api(client, "load", {"path": str(get_fixture_path("set", save))}) + gamestate = load_fixture(client, "set", "state-SELECTING_HAND") + assert gamestate["state"] == "SELECTING_HAND" response = api(client, "set", {"round": 5}) assert response["round_num"] == 5 def test_set_negative_hands(self, client: socket.socket) -> None: """Test that set fails when hands is negative.""" - save = "state-SELECTING_HAND.jkr" - api(client, "load", {"path": str(get_fixture_path("set", save))}) + gamestate = load_fixture(client, "set", "state-SELECTING_HAND") + assert gamestate["state"] == "SELECTING_HAND" response = api(client, "set", {"hands": -10}) assert_error_response( response, @@ -114,15 +114,15 @@ def test_set_negative_hands(self, client: socket.socket) -> None: def test_set_hands(self, client: socket.socket) -> None: """Test that set succeeds when hands is positive.""" - save = "state-SELECTING_HAND.jkr" - api(client, "load", {"path": str(get_fixture_path("set", save))}) + gamestate = load_fixture(client, "set", "state-SELECTING_HAND") + assert gamestate["state"] == "SELECTING_HAND" response = api(client, "set", {"hands": 10}) assert response["round"]["hands_left"] == 10 def test_set_negative_discards(self, client: socket.socket) -> None: """Test that set fails when discards is negative.""" - save = "state-SELECTING_HAND.jkr" - api(client, "load", {"path": str(get_fixture_path("set", save))}) + gamestate = load_fixture(client, "set", "state-SELECTING_HAND") + assert gamestate["state"] == "SELECTING_HAND" response = api(client, "set", {"discards": -10}) assert_error_response( response, @@ -132,15 +132,15 @@ def test_set_negative_discards(self, client: socket.socket) -> None: def test_set_discards(self, client: socket.socket) -> None: """Test that set succeeds when discards is positive.""" - save = "state-SELECTING_HAND.jkr" - api(client, "load", {"path": str(get_fixture_path("set", save))}) + gamestate = load_fixture(client, "set", "state-SELECTING_HAND") + assert gamestate["state"] == "SELECTING_HAND" response = api(client, "set", {"discards": 10}) assert response["round"]["discards_left"] == 10 def test_set_shop_from_selecting_hand(self, client: socket.socket) -> None: """Test that set fails when shop is called from SELECTING_HAND state.""" - save = "state-SELECTING_HAND.jkr" - api(client, "load", {"path": str(get_fixture_path("set", save))}) + gamestate = load_fixture(client, "set", "state-SELECTING_HAND") + assert gamestate["state"] == "SELECTING_HAND" response = api(client, "set", {"shop": True}) assert_error_response( response, @@ -150,10 +150,9 @@ def test_set_shop_from_selecting_hand(self, client: socket.socket) -> None: def test_set_shop_from_SHOP(self, client: socket.socket) -> None: """Test that set fails when shop is called from SHOP state.""" - save = "state-SHOP.jkr" - response = api(client, "load", {"path": str(get_fixture_path("set", save))}) - assert "error" not in response - before = api(client, "gamestate", {}) + gamestate = load_fixture(client, "set", "state-SHOP") + assert gamestate["state"] == "SHOP" + before = gamestate after = api(client, "set", {"shop": True}) assert len(after["shop"]["cards"]) > 0 assert len(before["shop"]["cards"]) > 0 @@ -163,10 +162,9 @@ def test_set_shop_from_SHOP(self, client: socket.socket) -> None: def test_set_shop_set_round_set_money(self, client: socket.socket) -> None: """Test that set fails when shop is called from SHOP state.""" - save = "state-SHOP.jkr" - response = api(client, "load", {"path": str(get_fixture_path("set", save))}) - assert "error" not in response - before = api(client, "gamestate", {}) + gamestate = load_fixture(client, "set", "state-SHOP") + assert gamestate["state"] == "SHOP" + before = gamestate after = api(client, "set", {"shop": True, "round": 5, "money": 100}) assert after["shop"] != before["shop"] assert after["packs"] != before["packs"] @@ -180,8 +178,8 @@ class TestSetEndpointValidation: def test_invalid_money_type(self, client: socket.socket): """Test that set fails when money parameter is not an integer.""" - save = "state-SELECTING_HAND.jkr" - api(client, "load", {"path": str(get_fixture_path("set", save))}) + gamestate = load_fixture(client, "set", "state-SELECTING_HAND") + assert gamestate["state"] == "SELECTING_HAND" response = api(client, "set", {"money": "INVALID_STRING"}) assert_error_response( response, @@ -191,8 +189,8 @@ def test_invalid_money_type(self, client: socket.socket): def test_invalid_chips_type(self, client: socket.socket): """Test that set fails when chips parameter is not an integer.""" - save = "state-SELECTING_HAND.jkr" - api(client, "load", {"path": str(get_fixture_path("set", save))}) + gamestate = load_fixture(client, "set", "state-SELECTING_HAND") + assert gamestate["state"] == "SELECTING_HAND" response = api(client, "set", {"chips": "INVALID_STRING"}) assert_error_response( response, @@ -202,8 +200,8 @@ def test_invalid_chips_type(self, client: socket.socket): def test_invalid_ante_type(self, client: socket.socket): """Test that set fails when ante parameter is not an integer.""" - save = "state-SELECTING_HAND.jkr" - api(client, "load", {"path": str(get_fixture_path("set", save))}) + gamestate = load_fixture(client, "set", "state-SELECTING_HAND") + assert gamestate["state"] == "SELECTING_HAND" response = api(client, "set", {"ante": "INVALID_STRING"}) assert_error_response( response, @@ -213,8 +211,8 @@ def test_invalid_ante_type(self, client: socket.socket): def test_invalid_round_type(self, client: socket.socket): """Test that set fails when round parameter is not an integer.""" - save = "state-SELECTING_HAND.jkr" - api(client, "load", {"path": str(get_fixture_path("set", save))}) + gamestate = load_fixture(client, "set", "state-SELECTING_HAND") + assert gamestate["state"] == "SELECTING_HAND" response = api(client, "set", {"round": "INVALID_STRING"}) assert_error_response( response, @@ -224,8 +222,8 @@ def test_invalid_round_type(self, client: socket.socket): def test_invalid_hands_type(self, client: socket.socket): """Test that set fails when hands parameter is not an integer.""" - save = "state-SELECTING_HAND.jkr" - api(client, "load", {"path": str(get_fixture_path("set", save))}) + gamestate = load_fixture(client, "set", "state-SELECTING_HAND") + assert gamestate["state"] == "SELECTING_HAND" response = api(client, "set", {"hands": "INVALID_STRING"}) assert_error_response( response, @@ -235,8 +233,8 @@ def test_invalid_hands_type(self, client: socket.socket): def test_invalid_discards_type(self, client: socket.socket): """Test that set fails when discards parameter is not an integer.""" - save = "state-SELECTING_HAND.jkr" - api(client, "load", {"path": str(get_fixture_path("set", save))}) + gamestate = load_fixture(client, "set", "state-SELECTING_HAND") + assert gamestate["state"] == "SELECTING_HAND" response = api(client, "set", {"discards": "INVALID_STRING"}) assert_error_response( response, @@ -246,8 +244,8 @@ def test_invalid_discards_type(self, client: socket.socket): def test_invalid_shop_type(self, client: socket.socket): """Test that set fails when shop parameter is not a boolean.""" - save = "state-SHOP.jkr" - api(client, "load", {"path": str(get_fixture_path("set", save))}) + gamestate = load_fixture(client, "set", "state-SHOP") + assert gamestate["state"] == "SHOP" response = api(client, "set", {"shop": "INVALID_STRING"}) assert_error_response( response, diff --git a/tests/lua/endpoints/test_skip.py b/tests/lua/endpoints/test_skip.py index 9330a1a..028e86d 100644 --- a/tests/lua/endpoints/test_skip.py +++ b/tests/lua/endpoints/test_skip.py @@ -3,7 +3,7 @@ import socket from typing import Any -from tests.lua.conftest import api, assert_error_response, get_fixture_path +from tests.lua.conftest import api, assert_error_response, load_fixture def verify_skip_response(response: dict[str, Any]) -> None: @@ -23,8 +23,11 @@ class TestSkipEndpoint: def test_skip_small_blind(self, client: socket.socket) -> None: """Test skipping Small blind in BLIND_SELECT state.""" - save = "state-BLIND_SELECT--blinds.small.status-SELECT.jkr" - api(client, "load", {"path": str(get_fixture_path("skip", save))}) + 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", {}) verify_skip_response(response) assert response["blinds"]["small"]["status"] == "SKIPPED" @@ -32,8 +35,11 @@ def test_skip_small_blind(self, client: socket.socket) -> None: def test_skip_big_blind(self, client: socket.socket) -> None: """Test skipping Big blind in BLIND_SELECT state.""" - save = "state-BLIND_SELECT--blinds.big.status-SELECT.jkr" - api(client, "load", {"path": str(get_fixture_path("skip", save))}) + 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", {}) verify_skip_response(response) assert response["blinds"]["big"]["status"] == "SKIPPED" @@ -41,13 +47,15 @@ def test_skip_big_blind(self, client: socket.socket) -> None: def test_skip_big_boss(self, client: socket.socket) -> None: """Test skipping Boss in BLIND_SELECT state.""" - save = "state-BLIND_SELECT--blinds.boss.status-SELECT.jkr" - api(client, "load", {"path": str(get_fixture_path("skip", save))}) - response = api(client, "skip", {}) + 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( - response, - expected_error_code="GAME_INVALID_STATE", - expected_message_contains="Cannot skip Boss blind", + api(client, "skip", {}), + "GAME_INVALID_STATE", + "Cannot skip Boss blind", ) @@ -58,9 +66,8 @@ def test_skip_from_MENU(self, client: socket.socket): """Test that skip fails when not in BLIND_SELECT state.""" response = api(client, "menu", {}) assert response["state"] == "MENU" - response = api(client, "skip", {}) assert_error_response( - response, - expected_error_code="STATE_INVALID_STATE", - expected_message_contains="Endpoint 'skip' requires one of these states:", + api(client, "skip", {}), + "STATE_INVALID_STATE", + "Endpoint 'skip' requires one of these states:", ) diff --git a/tests/lua/endpoints/test_start.py b/tests/lua/endpoints/test_start.py index 3aa180f..a6a2635 100644 --- a/tests/lua/endpoints/test_start.py +++ b/tests/lua/endpoints/test_start.py @@ -5,7 +5,7 @@ import pytest -from tests.lua.conftest import api, assert_error_response, get_fixture_path +from tests.lua.conftest import api, assert_error_response, load_fixture class TestStartEndpoint: @@ -78,21 +78,10 @@ def test_start_from_MENU( class TestStartEndpointValidation: """Test start endpoint parameter validation.""" - @pytest.fixture(scope="class") - def client(self, host: str, port: int): - """Class-scoped client fixture for this test class.""" - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: - sock.settimeout(60) - sock.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, 65536) - sock.connect((host, port)) - - response = api(sock, "menu", {}) - assert response["state"] == "MENU" - - yield sock - def test_missing_deck_parameter(self, client: socket.socket): """Test that start fails when deck parameter is missing.""" + response = api(client, "menu", {}) + assert response["state"] == "MENU" response = api(client, "start", {"stake": "WHITE"}) assert_error_response( response, @@ -102,6 +91,8 @@ def test_missing_deck_parameter(self, client: socket.socket): def test_missing_stake_parameter(self, client: socket.socket): """Test that start fails when stake parameter is missing.""" + response = api(client, "menu", {}) + assert response["state"] == "MENU" response = api(client, "start", {"deck": "RED"}) assert_error_response( response, @@ -111,6 +102,8 @@ def test_missing_stake_parameter(self, client: socket.socket): def test_invalid_deck_value(self, client: socket.socket): """Test that start fails with invalid deck enum.""" + response = api(client, "menu", {}) + assert response["state"] == "MENU" response = api(client, "start", {"deck": "INVALID_DECK", "stake": "WHITE"}) assert_error_response( response, @@ -120,6 +113,8 @@ def test_invalid_deck_value(self, client: socket.socket): def test_invalid_stake_value(self, client: socket.socket): """Test that start fails when invalid stake enum is provided.""" + response = api(client, "menu", {}) + assert response["state"] == "MENU" response = api(client, "start", {"deck": "RED", "stake": "INVALID_STAKE"}) assert_error_response( response, @@ -129,6 +124,8 @@ def test_invalid_stake_value(self, client: socket.socket): def test_invalid_deck_type(self, client: socket.socket): """Test that start fails when deck is not a string.""" + response = api(client, "menu", {}) + assert response["state"] == "MENU" response = api(client, "start", {"deck": 123, "stake": "WHITE"}) assert_error_response( response, @@ -138,6 +135,8 @@ def test_invalid_deck_type(self, client: socket.socket): def test_invalid_stake_type(self, client: socket.socket): """Test that start fails when stake is not a string.""" + response = api(client, "menu", {}) + assert response["state"] == "MENU" response = api(client, "start", {"deck": "RED", "stake": 1}) assert_error_response( response, @@ -151,8 +150,8 @@ class TestStartEndpointStateRequirements: def test_start_from_BLIND_SELECT(self, client: socket.socket): """Test that start fails when not in MENU state.""" - save = "state-BLIND_SELECT.jkr" - response = api(client, "load", {"path": str(get_fixture_path("start", save))}) + 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, From 0c5f15490f41e72158e9914b507176b90e4ab09a Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Wed, 26 Nov 2025 17:00:01 +0100 Subject: [PATCH 132/230] test(lua.endpoints): fix test_load using load_fixture function --- tests/lua/endpoints/test_load.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/lua/endpoints/test_load.py b/tests/lua/endpoints/test_load.py index 4d8de79..7d9ae56 100644 --- a/tests/lua/endpoints/test_load.py +++ b/tests/lua/endpoints/test_load.py @@ -8,6 +8,7 @@ assert_error_response, assert_success_response, get_fixture_path, + load_fixture, ) @@ -16,6 +17,8 @@ class TestLoadEndpoint: def test_load_from_fixture(self, client: socket.socket) -> 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_success_response(response) @@ -24,6 +27,8 @@ def test_load_from_fixture(self, client: socket.socket) -> None: def test_load_save_roundtrip(self, client: socket.socket, 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_success_response(load_response) From f5f934052f44f26c51b01b82326270c4240a1415 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Wed, 26 Nov 2025 19:30:02 +0100 Subject: [PATCH 133/230] fix(lua.endpoints): remove no_delete flag from events for which it is not needed --- src/lua/endpoints/buy.lua | 1 - src/lua/endpoints/discard.lua | 1 - src/lua/endpoints/play.lua | 11 ++++++++--- src/lua/endpoints/select.lua | 1 - src/lua/endpoints/skip.lua | 1 - 5 files changed, 8 insertions(+), 7 deletions(-) diff --git a/src/lua/endpoints/buy.lua b/src/lua/endpoints/buy.lua index 59df0e8..da670ab 100644 --- a/src/lua/endpoints/buy.lua +++ b/src/lua/endpoints/buy.lua @@ -182,7 +182,6 @@ return { -- Wait for buy completion with comprehensive verification G.E_MANAGER:add_event(Event({ - no_delete = true, trigger = "condition", blocking = false, func = function() diff --git a/src/lua/endpoints/discard.lua b/src/lua/endpoints/discard.lua index 5edb4e4..4e94898 100644 --- a/src/lua/endpoints/discard.lua +++ b/src/lua/endpoints/discard.lua @@ -74,7 +74,6 @@ return { local draw_to_hand = false G.E_MANAGER:add_event(Event({ - no_delete = true, trigger = "immediate", blocking = false, blockable = false, diff --git a/src/lua/endpoints/play.lua b/src/lua/endpoints/play.lua index e5b4f2c..ef52a26 100644 --- a/src/lua/endpoints/play.lua +++ b/src/lua/endpoints/play.lua @@ -73,8 +73,7 @@ return { BB_GAMESTATE.on_game_over = send_response G.E_MANAGER:add_event(Event({ - no_delete = true, - trigger = "immediate", + trigger = "condition", blocking = false, blockable = false, created_on_pause = true, @@ -94,19 +93,24 @@ return { draw_to_hand = true end - -- NOTE: GAME_OVER is detected by gamestate.on_game_over callback in love.update + -- 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 and G.round_eval then -- Go to the cash out stage for _, b in ipairs(G.I.UIBOX) do if b:get_UIE_by_ID("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 -- 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 @@ -114,6 +118,7 @@ return { 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 diff --git a/src/lua/endpoints/select.lua b/src/lua/endpoints/select.lua index db75238..09cc584 100644 --- a/src/lua/endpoints/select.lua +++ b/src/lua/endpoints/select.lua @@ -21,7 +21,6 @@ return { -- Wait for completion: transition to SELECTING_HAND with facing_blind flag set G.E_MANAGER:add_event(Event({ - no_delete = true, trigger = "condition", blocking = false, func = function() diff --git a/src/lua/endpoints/skip.lua b/src/lua/endpoints/skip.lua index cb018ba..aa2aaec 100644 --- a/src/lua/endpoints/skip.lua +++ b/src/lua/endpoints/skip.lua @@ -45,7 +45,6 @@ return { -- Wait for the skip to complete -- Completion is indicated by the blind state changing to "Skipped" G.E_MANAGER:add_event(Event({ - no_delete = true, trigger = "condition", blocking = true, func = function() From 6d7f20bb961a926ca16787d14b2480959ccc2941 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Wed, 26 Nov 2025 19:32:37 +0100 Subject: [PATCH 134/230] chore(fixtures): remove unused argparse import --- tests/fixtures/generate.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/fixtures/generate.py b/tests/fixtures/generate.py index 18ad830..79abdf2 100644 --- a/tests/fixtures/generate.py +++ b/tests/fixtures/generate.py @@ -1,6 +1,5 @@ #!/usr/bin/env python3 -import argparse import json import socket from collections import defaultdict From db1f2d635bf41f5dce79ee385d7d160f5b76dee2 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Wed, 26 Nov 2025 22:16:58 +0100 Subject: [PATCH 135/230] fix(fixtures): add $schema to fixtures schema --- tests/fixtures/fixtures.schema.json | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/fixtures/fixtures.schema.json b/tests/fixtures/fixtures.schema.json index 15fbaa4..d6d4152 100644 --- a/tests/fixtures/fixtures.schema.json +++ b/tests/fixtures/fixtures.schema.json @@ -2,6 +2,12 @@ "$schema": "http://json-schema.org/draft-07/schema#", "title": "Balatro API Test Fixtures Schema", "type": "object", + "properties": { + "$schema": { + "type": "string", + "description": "JSON Schema reference" + } + }, "additionalProperties": { "type": "object", "additionalProperties": { From 665d5c10dea20b9992ca92624fcc8eae2bb47a1c Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Thu, 27 Nov 2025 12:58:01 +0100 Subject: [PATCH 136/230] feat(lua.endpoints): add rearrange endpoint --- balatrobot.lua | 2 + src/lua/endpoints/rearrange.lua | 204 ++++++++++++++++++++++++++++++++ 2 files changed, 206 insertions(+) create mode 100644 src/lua/endpoints/rearrange.lua diff --git a/balatrobot.lua b/balatrobot.lua index fe8585e..f7403a3 100644 --- a/balatrobot.lua +++ b/balatrobot.lua @@ -29,6 +29,8 @@ BB_ENDPOINTS = { "src/lua/endpoints/next_round.lua", "src/lua/endpoints/reroll.lua", "src/lua/endpoints/buy.lua", + -- Rearrange endpoint + "src/lua/endpoints/rearrange.lua", -- If debug mode is enabled, debugger.lua will load test endpoints } diff --git a/src/lua/endpoints/rearrange.lua b/src/lua/endpoints/rearrange.lua new file mode 100644 index 0000000..06967ef --- /dev/null +++ b/src/lua/endpoints/rearrange.lua @@ -0,0 +1,204 @@ +-- src/lua/endpoints/rearrange.lua +-- Rearrange Endpoint +-- +-- Rearrange cards in hand, jokers, or consumables + +---@class Endpoint.Rearrange.Args +---@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 +-- Exactly one parameter must be provided + +---@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 Endpoint.Rearrange.Args The arguments (hand, jokers, or consumables) + ---@param send_response fun(response: table) Callback to send response + 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({ + error = "Must provide exactly one of: hand, jokers, or consumables", + error_code = BB_ERRORS.SCHEMA_INVALID_VALUE, + }) + return + elseif param_count > 1 then + send_response({ + error = "Can only rearrange one type at a time", + error_code = BB_ERRORS.SCHEMA_INVALID_VALUE, + }) + 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({ + error = "Can only rearrange hand during hand selection", + error_code = BB_ERRORS.STATE_INVALID_STATE, + }) + return + end + + -- Validate G.hand exists (not tested) + if not G.hand or not G.hand.cards then + send_response({ + error = "No hand available to rearrange", + error_code = BB_ERRORS.GAME_INVALID_STATE, + }) + 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({ + error = "No jokers available to rearrange", + error_code = BB_ERRORS.GAME_INVALID_STATE, + }) + 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({ + error = "No consumables available to rearrange", + error_code = BB_ERRORS.GAME_INVALID_STATE, + }) + 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({ + error = "Must provide exactly " .. #source_array .. " indices for " .. type_name, + error_code = BB_ERRORS.SCHEMA_INVALID_VALUE, + }) + 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({ + error = "Index out of range for " .. type_name .. ": " .. idx, + error_code = BB_ERRORS.SCHEMA_INVALID_VALUE, + }) + return + end + + -- Check for duplicates + if seen[idx] then + send_response({ + error = "Duplicate index in " .. type_name .. ": " .. idx, + error_code = BB_ERRORS.SCHEMA_INVALID_VALUE, + }) + 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("rearrange() completed", "BB.ENDPOINTS") + local state_data = BB_GAMESTATE.get_gamestate() + send_response(state_data) + end + return done + end, + })) + end, +} From a1a3f97b2f91cfc7ea1f17af77af2d885444908e Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Thu, 27 Nov 2025 12:58:14 +0100 Subject: [PATCH 137/230] test(fixtures): add rearrange fixtures --- tests/fixtures/fixtures.json | 115 +++++++++++++++++++++++++++++++++++ 1 file changed, 115 insertions(+) diff --git a/tests/fixtures/fixtures.json b/tests/fixtures/fixtures.json index 7df391e..47b776c 100644 --- a/tests/fixtures/fixtures.json +++ b/tests/fixtures/fixtures.json @@ -1108,5 +1108,120 @@ } } ] + }, + "rearrange": { + "state-BLIND_SELECT": [ + { + "endpoint": "menu", + "arguments": {} + }, + { + "endpoint": "start", + "arguments": { + "deck": "RED", + "stake": "WHITE", + "seed": "TEST123" + } + } + ], + "state-SELECTING_HAND--hand.count-8": [ + { + "endpoint": "menu", + "arguments": {} + }, + { + "endpoint": "start", + "arguments": { + "deck": "RED", + "stake": "WHITE", + "seed": "TEST123" + } + }, + { + "endpoint": "select", + "arguments": {} + } + ], + "state-SHOP--jokers.count-4--consumables.count-2": [ + { + "endpoint": "menu", + "arguments": {} + }, + { + "endpoint": "start", + "arguments": { + "deck": "RED", + "stake": "WHITE", + "seed": "TEST123" + } + }, + { + "endpoint": "select", + "arguments": {} + }, + { + "endpoint": "set", + "arguments": { + "chips": 1000, + "money": 1000 + } + }, + { + "endpoint": "play", + "arguments": { + "cards": [ + 0 + ] + } + }, + { + "endpoint": "cash_out", + "arguments": {} + }, + { + "endpoint": "buy", + "arguments": { + "card": 0 + } + }, + { + "endpoint": "buy", + "arguments": { + "card": 0 + } + }, + { + "endpoint": "reroll", + "arguments": {} + }, + { + "endpoint": "buy", + "arguments": { + "card": 0 + } + }, + { + "endpoint": "buy", + "arguments": { + "card": 0 + } + }, + { + "endpoint": "reroll", + "arguments": {} + }, + { + "endpoint": "buy", + "arguments": { + "card": 0 + } + }, + { + "endpoint": "buy", + "arguments": { + "card": 0 + } + } + ] } } From ca9b1ba86bc531a2a51fc1e5c048b6473c67aea6 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Thu, 27 Nov 2025 12:58:35 +0100 Subject: [PATCH 138/230] test(lua.endpoints): add test for rearrange endpoint --- tests/lua/endpoints/test_rearrange.py | 232 ++++++++++++++++++++++++++ 1 file changed, 232 insertions(+) create mode 100644 tests/lua/endpoints/test_rearrange.py diff --git a/tests/lua/endpoints/test_rearrange.py b/tests/lua/endpoints/test_rearrange.py new file mode 100644 index 0000000..08eec1c --- /dev/null +++ b/tests/lua/endpoints/test_rearrange.py @@ -0,0 +1,232 @@ +"""Tests for src/lua/endpoints/rearrange.lua""" + +import socket + +import pytest + +from tests.lua.conftest import api, assert_error_response, load_fixture + + +class TestRearrangeEndpoint: + """Test basic rearrange endpoint functionality.""" + + def test_rearrange_hand(self, client: socket.socket) -> None: + """Test rearranging hand in selecting hand state.""" + gamestate = load_fixture( + client, "rearrange", "state-SELECTING_HAND--hand.count-8" + ) + assert gamestate["state"] == "SELECTING_HAND" + assert gamestate["hand"]["count"] == 8 + ids = [card["id"] for card in gamestate["hand"]["cards"]] + assert ids == [62, 50, 35, 60, 58, 55, 54, 28] + response = api( + client, + "rearrange", + {"hand": [1, 2, 0, 3, 4, 5, 7, 6]}, + ) + ids = [card["id"] for card in response["hand"]["cards"]] + assert ids == [50, 35, 62, 60, 58, 55, 28, 54] + + def test_rearrange_jokers(self, client: socket.socket) -> None: + """Test rearranging jokers.""" + gamestate = load_fixture( + client, "rearrange", "state-SHOP--jokers.count-4--consumables.count-2" + ) + assert gamestate["state"] == "SHOP" + assert gamestate["jokers"]["count"] == 4 + ids = [card["id"] for card in gamestate["jokers"]["cards"]] + assert ids == [184, 189, 191, 192] + response = api( + client, + "rearrange", + {"jokers": [2, 0, 1, 3]}, + ) + ids = [card["id"] for card in response["jokers"]["cards"]] + assert ids == [191, 184, 189, 192] + + def test_rearrange_consumables(self, client: socket.socket) -> None: + """Test rearranging consumables.""" + gamestate = load_fixture( + client, "rearrange", "state-SHOP--jokers.count-4--consumables.count-2" + ) + assert gamestate["state"] == "SHOP" + assert gamestate["consumables"]["count"] == 2 + ids = [card["id"] for card in gamestate["consumables"]["cards"]] + assert ids == [185, 190] + response = api( + client, + "rearrange", + {"consumables": [1, 0]}, + ) + ids = [card["id"] for card in response["consumables"]["cards"]] + assert ids == [190, 185] + + +class TestRearrangeEndpointValidation: + """Test rearrange endpoint parameter validation.""" + + def test_no_parameters_provided(self, client: socket.socket) -> 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, + "SCHEMA_INVALID_VALUE", + "Must provide exactly one of: hand, jokers, or consumables", + ) + + def test_multiple_parameters_provided(self, client: socket.socket) -> 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, + "SCHEMA_INVALID_VALUE", + "Can only rearrange one type at a time", + ) + + def test_wrong_array_length_hand(self, client: socket.socket) -> 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, + "SCHEMA_INVALID_VALUE", + "Must provide exactly 8 indices for hand", + ) + + def test_wrong_array_length_jokers(self, client: socket.socket) -> 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, + "SCHEMA_INVALID_VALUE", + "Must provide exactly 4 indices for jokers", + ) + + def test_wrong_array_length_consumables(self, client: socket.socket) -> 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, + "SCHEMA_INVALID_VALUE", + "Must provide exactly 2 indices for consumables", + ) + + def test_invalid_card_index(self, client: socket.socket) -> 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, + "SCHEMA_INVALID_VALUE", + "Index out of range for hand: -1", + ) + + def test_duplicate_indices(self, client: socket.socket) -> 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, + "SCHEMA_INVALID_VALUE", + "Duplicate index in hand: 1", + ) + + +class TestRearrangeEndpointStateRequirements: + """Test rearrange endpoint state requirements.""" + + def test_rearrange_hand_from_wrong_state(self, client: socket.socket) -> 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]}), + "STATE_INVALID_STATE", + "Endpoint 'rearrange' requires one of these states: 1, 5", + ) + + def test_rearrange_jokers_from_wrong_state(self, client: socket.socket) -> 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]}), + "STATE_INVALID_STATE", + "Endpoint 'rearrange' requires one of these states: 1, 5", + ) + + def test_rearrange_consumables_from_wrong_state( + self, client: socket.socket + ) -> 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]}), + "STATE_INVALID_STATE", + "Endpoint 'rearrange' requires one of these states: 1, 5", + ) + + def test_rearrange_hand_from_shop(self, client: socket.socket) -> 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]}), + "STATE_INVALID_STATE", + "Can only rearrange hand during hand selection", + ) From 3eceba00687b9c38638c5106e1fe3074ae6abb4e Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Thu, 27 Nov 2025 13:09:27 +0100 Subject: [PATCH 139/230] test(lua.endpoints): fix assertion for rearrange endpoint for valid permutation --- tests/lua/endpoints/test_rearrange.py | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/tests/lua/endpoints/test_rearrange.py b/tests/lua/endpoints/test_rearrange.py index 08eec1c..c23ca28 100644 --- a/tests/lua/endpoints/test_rearrange.py +++ b/tests/lua/endpoints/test_rearrange.py @@ -17,15 +17,15 @@ def test_rearrange_hand(self, client: socket.socket) -> None: ) assert gamestate["state"] == "SELECTING_HAND" assert gamestate["hand"]["count"] == 8 - ids = [card["id"] for card in gamestate["hand"]["cards"]] - assert ids == [62, 50, 35, 60, 58, 55, 54, 28] + prev_ids = [card["id"] for card in gamestate["hand"]["cards"]] + permutation = [1, 2, 0, 3, 4, 5, 7, 6] response = api( client, "rearrange", - {"hand": [1, 2, 0, 3, 4, 5, 7, 6]}, + {"hand": permutation}, ) ids = [card["id"] for card in response["hand"]["cards"]] - assert ids == [50, 35, 62, 60, 58, 55, 28, 54] + assert ids == [prev_ids[i] for i in permutation] def test_rearrange_jokers(self, client: socket.socket) -> None: """Test rearranging jokers.""" @@ -34,15 +34,15 @@ def test_rearrange_jokers(self, client: socket.socket) -> None: ) assert gamestate["state"] == "SHOP" assert gamestate["jokers"]["count"] == 4 - ids = [card["id"] for card in gamestate["jokers"]["cards"]] - assert ids == [184, 189, 191, 192] + prev_ids = [card["id"] for card in gamestate["jokers"]["cards"]] + permutation = [2, 0, 1, 3] response = api( client, "rearrange", - {"jokers": [2, 0, 1, 3]}, + {"jokers": permutation}, ) ids = [card["id"] for card in response["jokers"]["cards"]] - assert ids == [191, 184, 189, 192] + assert ids == [prev_ids[i] for i in permutation] def test_rearrange_consumables(self, client: socket.socket) -> None: """Test rearranging consumables.""" @@ -51,15 +51,15 @@ def test_rearrange_consumables(self, client: socket.socket) -> None: ) assert gamestate["state"] == "SHOP" assert gamestate["consumables"]["count"] == 2 - ids = [card["id"] for card in gamestate["consumables"]["cards"]] - assert ids == [185, 190] + prev_ids = [card["id"] for card in gamestate["consumables"]["cards"]] + permutation = [1, 0] response = api( client, "rearrange", - {"consumables": [1, 0]}, + {"consumables": permutation}, ) ids = [card["id"] for card in response["consumables"]["cards"]] - assert ids == [190, 185] + assert ids == [prev_ids[i] for i in permutation] class TestRearrangeEndpointValidation: From 43ad5e17915668177d955dc2923acf4ce84752de Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Thu, 27 Nov 2025 14:16:29 +0100 Subject: [PATCH 140/230] fix(lua.endpoints): wait for blind_select to properly finish --- src/lua/endpoints/next_round.lua | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/lua/endpoints/next_round.lua b/src/lua/endpoints/next_round.lua index 7a8b076..87bab35 100644 --- a/src/lua/endpoints/next_round.lua +++ b/src/lua/endpoints/next_round.lua @@ -24,7 +24,9 @@ return { trigger = "condition", blocking = false, func = function() - local done = G.STATE == G.STATES.BLIND_SELECT + 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()) From 5595b7eb4517def3a9148d8f3324f87f4e6e4967 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Thu, 27 Nov 2025 14:17:23 +0100 Subject: [PATCH 141/230] feat(lua.endpoints): add sell endpoint --- balatrobot.lua | 2 + src/lua/endpoints/sell.lua | 147 +++++++++++++++++++++++++++++++++++++ 2 files changed, 149 insertions(+) create mode 100644 src/lua/endpoints/sell.lua diff --git a/balatrobot.lua b/balatrobot.lua index f7403a3..a089c67 100644 --- a/balatrobot.lua +++ b/balatrobot.lua @@ -31,6 +31,8 @@ BB_ENDPOINTS = { "src/lua/endpoints/buy.lua", -- Rearrange endpoint "src/lua/endpoints/rearrange.lua", + -- Sell endpoint + "src/lua/endpoints/sell.lua", -- If debug mode is enabled, debugger.lua will load test endpoints } diff --git a/src/lua/endpoints/sell.lua b/src/lua/endpoints/sell.lua new file mode 100644 index 0000000..41d88c5 --- /dev/null +++ b/src/lua/endpoints/sell.lua @@ -0,0 +1,147 @@ +-- src/lua/endpoints/sell.lua +-- Sell Endpoint +-- +-- Sell a joker or consumable from player inventory + +---@class Endpoint.Sell.Args +---@field joker integer? 0-based index of joker to sell +---@field consumable integer? 0-based index of consumable to sell +-- One (and only one) parameter is required +-- Must be in SHOP or SELECTING_HAND state + +---@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 Endpoint.Sell.Args The arguments (joker or consumable) + ---@param send_response fun(response: table) Callback to send response + 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({ + error = "Must provide exactly one of: joker or consumable", + error_code = BB_ERRORS.SCHEMA_INVALID_VALUE, + }) + return + elseif param_count > 1 then + send_response({ + error = "Can only sell one item at a time", + error_code = BB_ERRORS.SCHEMA_INVALID_VALUE, + }) + 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.cards or #G.jokers.cards == 0 then + send_response({ + error = "No jokers available to sell", + error_code = BB_ERRORS.GAME_INVALID_STATE, + }) + 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.cards or #G.consumeables.cards == 0 then + send_response({ + error = "No consumables available to sell", + error_code = BB_ERRORS.GAME_INVALID_STATE, + }) + 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({ + error = "Index out of range for " .. sell_type .. ": " .. (pos - 1), + error_code = BB_ERRORS.SCHEMA_INVALID_VALUE, + }) + return + end + + local card = source_array[pos] + + -- Track initial state for completion verification + local initial_count = #source_array + 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_array = sell_type == "joker" and G.jokers.cards or G.consumeables.cards + + -- 1. Card count decreased by 1 + local count_decreased = (#current_array == 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("Sell completed successfully", "BB.ENDPOINTS") + send_response(BB_GAMESTATE.get_gamestate()) + return true + end + + return false + end, + })) + end, +} From 80f22533a006963d45566ad179a7ef750de6568e Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Thu, 27 Nov 2025 14:18:07 +0100 Subject: [PATCH 142/230] test(fixtures): add fixtures for sell tests --- tests/fixtures/fixtures.json | 172 +++++++++++++++++++++++++++++++++++ 1 file changed, 172 insertions(+) diff --git a/tests/fixtures/fixtures.json b/tests/fixtures/fixtures.json index 47b776c..e0e9947 100644 --- a/tests/fixtures/fixtures.json +++ b/tests/fixtures/fixtures.json @@ -1223,5 +1223,177 @@ } } ] + }, + "sell": { + "state-BLIND_SELECT": [ + { + "endpoint": "menu", + "arguments": {} + }, + { + "endpoint": "start", + "arguments": { + "deck": "RED", + "stake": "WHITE", + "seed": "TEST123" + } + } + ], + "state-ROUND_EVAL": [ + { + "endpoint": "menu", + "arguments": {} + }, + { + "endpoint": "start", + "arguments": { + "deck": "RED", + "stake": "WHITE", + "seed": "TEST123" + } + }, + { + "endpoint": "select", + "arguments": {} + }, + { + "endpoint": "set", + "arguments": { + "chips": 1000 + } + }, + { + "endpoint": "play", + "arguments": { + "cards": [ + 0 + ] + } + } + ], + "state-SELECTING_HAND--jokers.count-0--consumables.count-0": [ + { + "endpoint": "menu", + "arguments": {} + }, + { + "endpoint": "start", + "arguments": { + "deck": "RED", + "stake": "WHITE", + "seed": "TEST123" + } + }, + { + "endpoint": "select", + "arguments": {} + } + ], + "state-SHOP--jokers.count-1--consumables.count-1": [ + { + "endpoint": "menu", + "arguments": {} + }, + { + "endpoint": "start", + "arguments": { + "deck": "RED", + "stake": "WHITE", + "seed": "TEST123" + } + }, + { + "endpoint": "select", + "arguments": {} + }, + { + "endpoint": "set", + "arguments": { + "chips": 1000, + "money": 1000 + } + }, + { + "endpoint": "play", + "arguments": { + "cards": [ + 0 + ] + } + }, + { + "endpoint": "cash_out", + "arguments": {} + }, + { + "endpoint": "buy", + "arguments": { + "card": 0 + } + }, + { + "endpoint": "buy", + "arguments": { + "card": 0 + } + } + ], + "state-SELECTING_HAND--jokers.count-1--consumables.count-1": [ + { + "endpoint": "menu", + "arguments": {} + }, + { + "endpoint": "start", + "arguments": { + "deck": "RED", + "stake": "WHITE", + "seed": "TEST123" + } + }, + { + "endpoint": "select", + "arguments": {} + }, + { + "endpoint": "set", + "arguments": { + "chips": 1000, + "money": 1000 + } + }, + { + "endpoint": "play", + "arguments": { + "cards": [ + 0 + ] + } + }, + { + "endpoint": "cash_out", + "arguments": {} + }, + { + "endpoint": "buy", + "arguments": { + "card": 0 + } + }, + { + "endpoint": "buy", + "arguments": { + "card": 0 + } + }, + { + "endpoint": "next_round", + "arguments": {} + }, + { + "endpoint": "select", + "arguments": {} + } + ] } } From 048b277f7ee7a969e00975df9fe0caef2b04e11c Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Thu, 27 Nov 2025 14:18:25 +0100 Subject: [PATCH 143/230] test(lua.endpoints): add tests for sell endpoint --- tests/lua/endpoints/test_sell.py | 185 +++++++++++++++++++++++++++++++ 1 file changed, 185 insertions(+) create mode 100644 tests/lua/endpoints/test_sell.py diff --git a/tests/lua/endpoints/test_sell.py b/tests/lua/endpoints/test_sell.py new file mode 100644 index 0000000..709dce1 --- /dev/null +++ b/tests/lua/endpoints/test_sell.py @@ -0,0 +1,185 @@ +"""Tests for src/lua/endpoints/sell.lua""" + +import socket + +from tests.lua.conftest import api, assert_error_response, load_fixture + + +class TestSellEndpoint: + """Test basic sell endpoint functionality.""" + + def test_sell_no_args(self, client: socket.socket) -> 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", {}), + "SCHEMA_INVALID_VALUE", + "Must provide exactly one of: joker or consumable", + ) + + def test_sell_multi_args(self, client: socket.socket) -> 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}), + "SCHEMA_INVALID_VALUE", + "Can only sell one item at a time", + ) + + def test_sell_no_jokers(self, client: socket.socket) -> 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}), + "GAME_INVALID_STATE", + "No jokers available to sell", + ) + + def test_sell_no_consumables(self, client: socket.socket) -> 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}), + "GAME_INVALID_STATE", + "No consumables available to sell", + ) + + def test_sell_joker_invalid_index(self, client: socket.socket) -> 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}), + "SCHEMA_INVALID_VALUE", + "Index out of range for joker: 1", + ) + + def test_sell_consumable_invalid_index(self, client: socket.socket) -> 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}), + "SCHEMA_INVALID_VALUE", + "Index out of range for consumable: 1", + ) + + def test_sell_joker_in_SELECTING_HAND(self, client: socket.socket) -> None: + """Test selling a joker in SELECTING_HAND state.""" + gamestate = load_fixture( + client, + "sell", + "state-SELECTING_HAND--jokers.count-1--consumables.count-1", + ) + assert gamestate["state"] == "SELECTING_HAND" + assert gamestate["jokers"]["count"] == 1 + response = api(client, "sell", {"joker": 0}) + assert response["jokers"]["count"] == 0 + assert gamestate["money"] < response["money"] + + def test_sell_consumable_in_SELECTING_HAND(self, client: socket.socket) -> None: + """Test selling a consumable in SELECTING_HAND state.""" + gamestate = load_fixture( + client, "sell", "state-SELECTING_HAND--jokers.count-1--consumables.count-1" + ) + assert gamestate["state"] == "SELECTING_HAND" + assert gamestate["consumables"]["count"] == 1 + response = api(client, "sell", {"consumable": 0}) + assert response["consumables"]["count"] == 0 + assert gamestate["money"] < response["money"] + + def test_sell_joker_in_SHOP(self, client: socket.socket) -> None: + """Test selling a joker in SHOP state.""" + gamestate = load_fixture( + client, "sell", "state-SHOP--jokers.count-1--consumables.count-1" + ) + assert gamestate["state"] == "SHOP" + assert gamestate["jokers"]["count"] == 1 + response = api(client, "sell", {"joker": 0}) + assert response["jokers"]["count"] == 0 + assert gamestate["money"] < response["money"] + + def test_sell_consumable_in_SHOP(self, client: socket.socket) -> None: + """Test selling a consumable in SHOP state.""" + gamestate = load_fixture( + client, "sell", "state-SHOP--jokers.count-1--consumables.count-1" + ) + assert gamestate["state"] == "SHOP" + assert gamestate["consumables"]["count"] == 1 + response = api(client, "sell", {"consumable": 0}) + assert response["consumables"]["count"] == 0 + assert gamestate["money"] < response["money"] + + +class TestSellEndpointValidation: + """Test sell endpoint parameter validation.""" + + def test_invalid_joker_type_string(self, client: socket.socket) -> 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"}), + "SCHEMA_INVALID_TYPE", + "Field 'joker' must be an integer", + ) + + def test_invalid_consumable_type_string(self, client: socket.socket) -> 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"}), + "SCHEMA_INVALID_TYPE", + "Field 'consumable' must be an integer", + ) + + +class TestSellEndpointStateRequirements: + """Test sell endpoint state requirements.""" + + def test_sell_from_BLIND_SELECT(self, client: socket.socket) -> 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", {}), + "STATE_INVALID_STATE", + "Endpoint 'sell' requires one of these states: 1, 5", + ) + + def test_sell_from_ROUND_EVAL(self, client: socket.socket) -> 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", {}), + "STATE_INVALID_STATE", + "Endpoint 'sell' requires one of these states: 1, 5", + ) From b8e36c1c79812f0053b71fe0dda34e2b26d881ee Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Thu, 27 Nov 2025 14:19:31 +0100 Subject: [PATCH 144/230] chore(lua.endpoints): remove unused pytest import --- tests/lua/endpoints/test_rearrange.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/lua/endpoints/test_rearrange.py b/tests/lua/endpoints/test_rearrange.py index c23ca28..ae88a56 100644 --- a/tests/lua/endpoints/test_rearrange.py +++ b/tests/lua/endpoints/test_rearrange.py @@ -2,8 +2,6 @@ import socket -import pytest - from tests.lua.conftest import api, assert_error_response, load_fixture From 7d0440497698c180dbbfa6384f04ea21d9d19a61 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Thu, 27 Nov 2025 15:17:19 +0100 Subject: [PATCH 145/230] feat(utils.gamestate): fix rank and suit and add key field to Card --- src/lua/utils/gamestate.lua | 65 +++++++++++++++++++++++++++++++++++-- src/lua/utils/types.lua | 25 +++++++------- 2 files changed, 76 insertions(+), 14 deletions(-) diff --git a/src/lua/utils/gamestate.lua b/src/lua/utils/gamestate.lua index 6037cd7..4e0fdab 100644 --- a/src/lua/utils/gamestate.lua +++ b/src/lua/utils/gamestate.lua @@ -120,6 +120,56 @@ local function get_card_ui_description(card) 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 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 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 -- ========================================================================== @@ -172,10 +222,10 @@ local function extract_card_value(card) -- Suit and rank (for playing cards) if card.config and card.config.card then if card.config.card.suit then - value.suit = card.config.card.suit + value.suit = convert_suit_to_enum(card.config.card.suit) end if card.config.card.value then - value.value = card.config.card.value + value.rank = convert_rank_to_enum(card.config.card.value) end end @@ -250,8 +300,19 @@ local function extract_card(card) 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), diff --git a/src/lua/utils/types.lua b/src/lua/utils/types.lua index 73b6f3f..8693255 100644 --- a/src/lua/utils/types.lua +++ b/src/lua/utils/types.lua @@ -1,16 +1,5 @@ ---@meta types --- ========================================================================== --- Endpoint Type --- ========================================================================== - ----@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 string[]? Optional list of required game states ----@field execute fun(args: table, send_response: fun(response: table)) Execute function - -- ========================================================================== -- GameState Enums -- ========================================================================== @@ -190,6 +179,7 @@ ---@class Card ---@field id integer Unique identifier for the card (sort_id) +---@field key string Specific card key (e.g., "c_fool", "j_brainstorm, "v_overstock", ...) ---@field set Set Card set/type ---@field label string Display label/name of the card ---@field value Card.Value Value information for the card @@ -199,7 +189,7 @@ ---@class Card.Value ---@field suit Suit? Suit (Hearts, Diamonds, Clubs, Spades) - only for playing cards ----@field value Rank? Rank - only for playing cards +---@field rank Rank? Rank - only for playing cards ---@field effect string Description of the card's effect (from UI) ---@class Card.Modifier @@ -218,3 +208,14 @@ ---@class Card.Cost ---@field sell integer Sell value of the card ---@field buy integer Buy price of the card (if in shop) + +-- ========================================================================== +-- Endpoint Type +-- ========================================================================== + +---@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 string[]? Optional list of required game states +---@field execute fun(args: table, send_response: fun(response: table)) Execute function From d477d66eb1dc6e73c9a254ca81a67c61cd81af4c Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Thu, 27 Nov 2025 16:21:52 +0100 Subject: [PATCH 146/230] feat(lua.utils): move enums to enums.lua and add Card.Key enums --- src/lua/utils/enums.lua | 382 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 382 insertions(+) create mode 100644 src/lua/utils/enums.lua diff --git a/src/lua/utils/enums.lua b/src/lua/utils/enums.lua new file mode 100644 index 0000000..1aa43b0 --- /dev/null +++ b/src/lua/utils/enums.lua @@ -0,0 +1,382 @@ +---@meta enums + +-- ========================================================================== +-- GameState 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 +---| "UNKNOWN" # Not a number, we never expect this game state + +---@alias Card.Set +---| "BOOSTER" +---| "DEFAULT" +---| "EDITION" +---| "ENHANCED" +---| "JOKER" +---| "TAROT" +---| "PLANET" +---| "SPECTRAL" +---| "VOUCHER" + +---@alias Card.Value.Suit +---| "H" # Hearts +---| "D" # Diamonds +---| "C" # Clubs +---| "S" # Spades + +---@alias Card.Value.Rank +---| "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 + +---@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" +---| "BLUE" +---| "GOLD" +---| "PURPLE" + +---@alias Card.Modifier.Edition +---| "HOLO" +---| "FOIL" +---| "POLYCHROME" +---| "NEGATIVE" + +---@alias Card.Modifier.Enhancement +---| "BONUS" +---| "MULT" +---| "WILD" +---| "GLASS" +---| "STEEL" +---| "STONE" +---| "GOLD" +---| "LUCKY" + +---@alias Blind.Type +---| "SMALL" +---| "BIG" +---| "BOSS" + +---@alias Blind.Status +---| "SELECT" +---| "CURRENT" +---| "UPCOMING" +---| "DEFEATED" +---| "SKIPPED" From ca82931d5b57b530323ec6db1b542bd13e4aa549 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Thu, 27 Nov 2025 16:22:46 +0100 Subject: [PATCH 147/230] refactor(lua.utils): move enums to a enums.lua and update enums in types --- src/lua/utils/types.lua | 134 +++------------------------------------- 1 file changed, 7 insertions(+), 127 deletions(-) diff --git a/src/lua/utils/types.lua b/src/lua/utils/types.lua index 8693255..36e97a4 100644 --- a/src/lua/utils/types.lua +++ b/src/lua/utils/types.lua @@ -1,125 +1,5 @@ ---@meta types --- ========================================================================== --- GameState 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 ----| "UNKNOWN" # Not a number, we never expect this game state - ----@alias Set ----| "BOOSTER" ----| "DEFAULT" ----| "EDITION" ----| "ENHANCED" ----| "JOKER" ----| "TAROT" ----| "PLANET" ----| "SPECTRAL" ----| "VOUCHER" - ----@alias Seal ----| "RED" ----| "BLUE" ----| "GOLD" ----| "PURPLE" - ----@alias Edition ----| "HOLO" ----| "FOIL" ----| "POLYCHROME" ----| "NEGATIVE" - ----@alias Enhancement ----| "BONUS" ----| "MULT" ----| "WILD" ----| "GLASS" ----| "STEEL" ----| "STONE" ----| "GOLD" ----| "LUCKY" - ----@alias Suit ----| "H" # Hearts ----| "D" # Diamonds ----| "C" # Clubs ----| "S" # Spades - ----@alias Rank ----| "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 - ----@alias Blind.Type ----| "SMALL" ----| "BIG" ----| "BOSS" - ----@alias Blind.Status ----| "SELECT" ----| "CURRENT" ----| "UPCOMING" ----| "DEFEATED" ----| "SKIPPED" - -- ========================================================================== -- GameState Types -- ========================================================================== @@ -179,8 +59,8 @@ ---@class Card ---@field id integer Unique identifier for the card (sort_id) ----@field key string Specific card key (e.g., "c_fool", "j_brainstorm, "v_overstock", ...) ----@field set Set Card set/type +---@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) @@ -188,14 +68,14 @@ ---@field cost Card.Cost Cost information (buy/sell prices) ---@class Card.Value ----@field suit Suit? Suit (Hearts, Diamonds, Clubs, Spades) - only for playing cards ----@field rank Rank? Rank - only for playing cards +---@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 Seal? Seal type ----@field edition Edition? Edition type ----@field enhancement Enhancement? Enhancement type +---@field seal Card.Modifier.Seal? Seal type +---@field edition Card.Modifier.Edition? Edition type +---@field enhancement Card.Modifier.Enhancement? Enhancement type ---@field eternal boolean? If true, card cannot be sold or destroyed ---@field perishable integer? Number of rounds remaining (only if > 0) ---@field rental boolean? If true, card costs money at end of round From d5e5e9c1bba3d25653865f60c5e36dc563eafad1 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Thu, 27 Nov 2025 18:53:46 +0100 Subject: [PATCH 148/230] feat(lua.endpoints): add `add` endpoint --- balatrobot.lua | 2 + src/lua/endpoints/add.lua | 207 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 209 insertions(+) create mode 100644 src/lua/endpoints/add.lua diff --git a/balatrobot.lua b/balatrobot.lua index a089c67..9d62a19 100644 --- a/balatrobot.lua +++ b/balatrobot.lua @@ -13,7 +13,9 @@ BB_ENDPOINTS = { -- Save/load endpoints "src/lua/endpoints/save.lua", "src/lua/endpoints/load.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", diff --git a/src/lua/endpoints/add.lua b/src/lua/endpoints/add.lua new file mode 100644 index 0000000..242ef8e --- /dev/null +++ b/src/lua/endpoints/add.lua @@ -0,0 +1,207 @@ +-- src/lua/endpoints/add.lua +-- Add Endpoint +-- +-- Add a new card to the game using SMODS.add_card + +---@class Endpoint.Add.Args +---@field key Card.Key The card key to add (j_* for jokers, c_* for consumables, v_* for vouchers, SUIT_RANK for playing cards) + +-- 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", +} + +---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 + +---@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)", + }, + }, + requires_state = { G.STATES.SELECTING_HAND, G.STATES.SHOP, G.STATES.ROUND_EVAL }, + + ---@param args Endpoint.Add.Args The arguments (key) + ---@param send_response fun(response: table) Callback to send response + 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({ + error = "Invalid card key format. Expected: joker (j_*), consumable (c_*), voucher (v_*), or playing card (SUIT_RANK)", + error_code = BB_ERRORS.SCHEMA_INVALID_VALUE, + }) + 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({ + error = "Playing cards can only be added in SELECTING_HAND state", + error_code = BB_ERRORS.STATE_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({ + error = "Vouchers can only be added in SHOP state", + error_code = BB_ERRORS.STATE_INVALID_STATE, + }) + 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, + } + elseif card_type == "voucher" then + params = { + key = args.key, + area = G.shop_vouchers, + } + else + -- For jokers and consumables - just pass the key + params = { + key = args.key, + } + end + + -- Track initial state for verification + local initial_joker_count = G.jokers and #G.jokers.cards or 0 + local initial_consumable_count = G.consumeables and #G.consumeables.cards or 0 + local initial_voucher_count = G.shop_vouchers and #G.shop_vouchers.cards or 0 + local initial_hand_count = G.hand and #G.hand.cards or 0 + + sendDebugMessage("Initial voucher count: " .. initial_voucher_count, "BB.ENDPOINTS") + + -- Call SMODS.add_card with error handling + local success, _ = pcall(SMODS.add_card, params) + + if not success then + send_response({ + error = "Failed to add card: " .. args.key, + error_code = BB_ERRORS.SCHEMA_INVALID_VALUE, + }) + return + 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 + + -- 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, +} From 2097cb896c0ff913dad8f39865ad33fecb5627af Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Thu, 27 Nov 2025 18:54:11 +0100 Subject: [PATCH 149/230] test(fixtures): add fixtures for add endpoint tests --- tests/fixtures/fixtures.json | 76 ++++++++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/tests/fixtures/fixtures.json b/tests/fixtures/fixtures.json index e0e9947..7cb4daa 100644 --- a/tests/fixtures/fixtures.json +++ b/tests/fixtures/fixtures.json @@ -1395,5 +1395,81 @@ "arguments": {} } ] + }, + "add": { + "state-BLIND_SELECT": [ + { + "endpoint": "menu", + "arguments": {} + }, + { + "endpoint": "start", + "arguments": { + "deck": "RED", + "stake": "WHITE", + "seed": "TEST123" + } + } + ], + "state-SELECTING_HAND--jokers.count-0--consumables.count-0--hand.count-8": [ + { + "endpoint": "menu", + "arguments": {} + }, + { + "endpoint": "start", + "arguments": { + "deck": "RED", + "stake": "WHITE", + "seed": "TEST123" + } + }, + { + "endpoint": "select", + "arguments": {} + } + ], + "state-SHOP--jokers.count-0--consumables.count-0--vouchers.count-0": [ + { + "endpoint": "menu", + "arguments": {} + }, + { + "endpoint": "start", + "arguments": { + "deck": "RED", + "stake": "WHITE", + "seed": "TEST123" + } + }, + { + "endpoint": "select", + "arguments": {} + }, + { + "endpoint": "set", + "arguments": { + "chips": 1000 + } + }, + { + "endpoint": "play", + "arguments": { + "cards": [ + 0 + ] + } + }, + { + "endpoint": "cash_out", + "arguments": {} + }, + { + "endpoint": "buy", + "arguments": { + "voucher": 0 + } + } + ] } } From 059aa3b5256e6f5b526cb64032ef3c23999f7c37 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Thu, 27 Nov 2025 18:54:28 +0100 Subject: [PATCH 150/230] test(lua.endpoints): add tests for `add` endpoint --- tests/lua/endpoints/test_add.py | 189 ++++++++++++++++++++++++++++++++ 1 file changed, 189 insertions(+) create mode 100644 tests/lua/endpoints/test_add.py diff --git a/tests/lua/endpoints/test_add.py b/tests/lua/endpoints/test_add.py new file mode 100644 index 0000000..5b250ea --- /dev/null +++ b/tests/lua/endpoints/test_add.py @@ -0,0 +1,189 @@ +"""Tests for src/lua/endpoints/add.lua""" + +import socket + +from tests.lua.conftest import api, assert_error_response, load_fixture + + +class TestAddEndpoint: + """Test basic add endpoint functionality.""" + + def test_add_joker(self, client: socket.socket) -> 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"}) + assert response["jokers"]["count"] == 1 + assert response["jokers"]["cards"][0]["key"] == "j_joker" + + def test_add_consumable_tarot(self, client: socket.socket) -> 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"}) + assert response["consumables"]["count"] == 1 + assert response["consumables"]["cards"][0]["key"] == "c_fool" + + def test_add_consumable_planet(self, client: socket.socket) -> 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"}) + assert response["consumables"]["count"] == 1 + assert response["consumables"]["cards"][0]["key"] == "c_mercury" + + def test_add_consumable_spectral(self, client: socket.socket) -> 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"}) + assert response["consumables"]["count"] == 1 + assert response["consumables"]["cards"][0]["key"] == "c_familiar" + + def test_add_voucher(self, client: socket.socket) -> 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"}) + assert response["vouchers"]["count"] == 1 + assert response["vouchers"]["cards"][0]["key"] == "v_overstock_norm" + + def test_add_playing_card(self, client: socket.socket) -> 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"}) + assert response["hand"]["count"] == 9 + assert response["hand"]["cards"][8]["key"] == "H_A" + + def test_add_no_key_provided(self, client: socket.socket) -> 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", {}), + "SCHEMA_MISSING_REQUIRED", + "Missing required field 'key'", + ) + + +class TestAddEndpointValidation: + """Test add endpoint parameter validation.""" + + def test_invalid_key_type_number(self, client: socket.socket) -> 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}), + "SCHEMA_INVALID_TYPE", + "Field 'key' must be of type string", + ) + + def test_invalid_key_unknown_format(self, client: socket.socket) -> 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"}), + "SCHEMA_INVALID_VALUE", + "Invalid card key format. Expected: joker (j_*), consumable (c_*), voucher (v_*), or playing card (SUIT_RANK)", + ) + + def test_invalid_key_known_format(self, client: socket.socket) -> 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"}), + "SCHEMA_INVALID_VALUE", + "Failed to add card: j_NON_EXTING_JOKER", + ) + + +class TestAddEndpointStateRequirements: + """Test add endpoint state requirements.""" + + def test_add_from_BLIND_SELECT(self, client: socket.socket) -> 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"}), + "STATE_INVALID_STATE", + "Endpoint 'add' requires one of these states: 1, 5, 8", + ) + + def test_add_playing_card_from_SHOP(self, client: socket.socket) -> 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"}), + "STATE_INVALID_STATE", + "Playing cards can only be added in SELECTING_HAND state", + ) + + def test_add_voucher_card_from_SELECTING_HAND(self, client: socket.socket) -> 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"}), + "STATE_INVALID_STATE", + "Vouchers can only be added in SHOP state", + ) From 98bd5613cad77cc2f12ab93157cb29e8c055d0c5 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Thu, 27 Nov 2025 19:12:09 +0100 Subject: [PATCH 151/230] fix(lua.endpoints): use of area.config.card_count which is more reliable --- src/lua/endpoints/add.lua | 8 ++++---- src/lua/endpoints/buy.lua | 21 +++++++++++++-------- src/lua/endpoints/sell.lua | 12 +++++++----- src/lua/endpoints/set.lua | 6 +++--- 4 files changed, 27 insertions(+), 20 deletions(-) diff --git a/src/lua/endpoints/add.lua b/src/lua/endpoints/add.lua index 242ef8e..d2d53bc 100644 --- a/src/lua/endpoints/add.lua +++ b/src/lua/endpoints/add.lua @@ -141,10 +141,10 @@ return { end -- Track initial state for verification - local initial_joker_count = G.jokers and #G.jokers.cards or 0 - local initial_consumable_count = G.consumeables and #G.consumeables.cards or 0 - local initial_voucher_count = G.shop_vouchers and #G.shop_vouchers.cards or 0 - local initial_hand_count = G.hand and #G.hand.cards or 0 + 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") diff --git a/src/lua/endpoints/buy.lua b/src/lua/endpoints/buy.lua index da670ab..0fd9d01 100644 --- a/src/lua/endpoints/buy.lua +++ b/src/lua/endpoints/buy.lua @@ -37,7 +37,10 @@ return { sendDebugMessage("Init buy()", "BB.ENDPOINTS") local gamestate = BB_GAMESTATE.get_gamestate() sendDebugMessage("Gamestate is : " .. gamestate.state, "BB.ENDPOINTS") - sendDebugMessage("Gamestate native is : " .. #G.consumeables.cards, "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 @@ -147,7 +150,9 @@ return { if args.card then initial_shop_count = gamestate.shop.count - initial_dest_count = gamestate.jokers.count + gamestate.consumables.count + (#G.deck.cards or 0) + 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 @@ -188,10 +193,10 @@ return { local done = false if args.card then - local shop_count = (G.shop_jokers and #G.shop_jokers.cards or 0) - local dest_count = (G.jokers and #G.jokers.cards or 0) - + (G.consumeables and #G.consumeables.cards or 0) - + (G.deck and #G.deck.cards or 0) + 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) @@ -199,7 +204,7 @@ return { done = true end elseif args.voucher then - local shop_count = (G.shop_vouchers and #G.shop_vouchers.cards or 0) + 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 @@ -215,7 +220,7 @@ return { 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.cards or 0) + 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 diff --git a/src/lua/endpoints/sell.lua b/src/lua/endpoints/sell.lua index 41d88c5..3e515b2 100644 --- a/src/lua/endpoints/sell.lua +++ b/src/lua/endpoints/sell.lua @@ -53,7 +53,7 @@ return { if args.joker then -- Validate G.jokers exists and has cards - if not G.jokers or not G.jokers.cards or #G.jokers.cards == 0 then + if not G.jokers or not G.jokers.config or G.jokers.config.card_count == 0 then send_response({ error = "No jokers available to sell", error_code = BB_ERRORS.GAME_INVALID_STATE, @@ -65,7 +65,7 @@ return { sell_type = "joker" else -- args.consumable -- Validate G.consumeables exists and has cards - if not G.consumeables or not G.consumeables.cards or #G.consumeables.cards == 0 then + if not G.consumeables or not G.consumeables.config or G.consumeables.config.card_count == 0 then send_response({ error = "No consumables available to sell", error_code = BB_ERRORS.GAME_INVALID_STATE, @@ -89,7 +89,8 @@ return { local card = source_array[pos] -- Track initial state for completion verification - local initial_count = #source_array + 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 @@ -110,10 +111,11 @@ return { blocking = false, func = function() -- Check all 5 completion criteria - local current_array = sell_type == "joker" and G.jokers.cards or G.consumeables.cards + 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_array == initial_count - 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) diff --git a/src/lua/endpoints/set.lua b/src/lua/endpoints/set.lua index 9aa27f1..39c499b 100644 --- a/src/lua/endpoints/set.lua +++ b/src/lua/endpoints/set.lua @@ -187,9 +187,9 @@ return { blocking = false, func = function() if args.shop then - local done_vouchers = G.shop_vouchers and G.shop_vouchers.cards and #G.shop_vouchers.cards > 0 - local done_packs = G.shop_booster and G.shop_booster.cards and #G.shop_booster.cards > 0 - local done_jokers = G.shop_jokers and G.shop_jokers.cards and #G.shop_jokers.cards > 0 + 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() From 82c100303a1d6622abb0b7f761c75f19570d0c91 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Fri, 28 Nov 2025 12:52:42 +0100 Subject: [PATCH 152/230] fix(lua.endpoints): skip materialize and check CONTROLLER state in `add` endpoint --- src/lua/endpoints/add.lua | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/lua/endpoints/add.lua b/src/lua/endpoints/add.lua index d2d53bc..3581bc1 100644 --- a/src/lua/endpoints/add.lua +++ b/src/lua/endpoints/add.lua @@ -127,16 +127,19 @@ return { params = { rank = rank, suit = suit, + skip_materialize = true, } 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, } end @@ -184,7 +187,7 @@ return { end -- Check state stability - local state_stable = G.STATE_COMPLETE == true + 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 = ( From d30a991a02ecd51d3d6f1e26127175d950a7ca1f Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Fri, 28 Nov 2025 14:09:04 +0100 Subject: [PATCH 153/230] fix(lua.endpoints): short circuit load if controller is locked or state is not complete --- src/lua/endpoints/load.lua | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/lua/endpoints/load.lua b/src/lua/endpoints/load.lua index 74ab505..dfa9fcc 100644 --- a/src/lua/endpoints/load.lua +++ b/src/lua/endpoints/load.lua @@ -101,6 +101,11 @@ return { 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 @@ -119,7 +124,7 @@ return { end end - if G.STATE == G.STATES.SHOP and G.STATE_COMPLETE then + 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 From 766d90c07f2fdb729493242d8348d7f593199dcd Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Fri, 28 Nov 2025 14:32:28 +0100 Subject: [PATCH 154/230] fix(lua.utils): remove " Card" suffix from enhancement in gamestate --- src/lua/utils/gamestate.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lua/utils/gamestate.lua b/src/lua/utils/gamestate.lua index 4e0fdab..0e6116c 100644 --- a/src/lua/utils/gamestate.lua +++ b/src/lua/utils/gamestate.lua @@ -192,7 +192,7 @@ local function extract_card_modifier(card) -- 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) + modifier.enhancement = string.upper(card.ability.effect:gsub(" Card", "")) end -- Eternal (boolean from ability) From 48fa0a11cac2ffd3d212bcc30ae50451dd8e97ac Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Fri, 28 Nov 2025 14:50:21 +0100 Subject: [PATCH 155/230] feat(lua.endpoints): add `use` endpoint --- balatrobot.lua | 2 + src/lua/endpoints/use.lua | 209 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 211 insertions(+) create mode 100644 src/lua/endpoints/use.lua diff --git a/balatrobot.lua b/balatrobot.lua index 9d62a19..837af30 100644 --- a/balatrobot.lua +++ b/balatrobot.lua @@ -35,6 +35,8 @@ BB_ENDPOINTS = { "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 } diff --git a/src/lua/endpoints/use.lua b/src/lua/endpoints/use.lua new file mode 100644 index 0000000..114629c --- /dev/null +++ b/src/lua/endpoints/use.lua @@ -0,0 +1,209 @@ +-- Use Endpoint +-- +-- Use a consumable card (Tarot, Planet, or Spectral) with optional target cards + +---@class Endpoint.Use.Args +---@field consumable integer 0-based index of consumable to use +---@field cards integer[]? 0-based indices of cards to target + +---@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 }, + + 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({ + error = "Consumable index out of range: " .. args.consumable, + error_code = BB_ERRORS.SCHEMA_INVALID_VALUE, + }) + 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({ + error = "Consumable '" + .. consumable_card.ability.name + .. "' requires card selection and can only be used in SELECTING_HAND state", + error_code = BB_ERRORS.STATE_INVALID_STATE, + }) + return + end + + -- Step 4: Cards Parameter Validation + if requires_cards then + if not args.cards or #args.cards == 0 then + send_response({ + error = "Consumable '" .. consumable_card.ability.name .. "' requires card selection", + error_code = BB_ERRORS.SCHEMA_MISSING_REQUIRED, + }) + return + end + + -- Validate each card index is in range + for i, card_idx in ipairs(args.cards) do + if card_idx < 0 or card_idx >= #G.hand.cards then + send_response({ + error = "Card index out of range: " .. card_idx, + error_code = BB_ERRORS.SCHEMA_INVALID_VALUE, + }) + 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({ + error = 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 + ), + error_code = BB_ERRORS.SCHEMA_INVALID_VALUE, + }) + return + end + + -- For consumables with range, check min and max separately + if card_count < min_cards then + send_response({ + error = 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 + ), + error_code = BB_ERRORS.SCHEMA_INVALID_VALUE, + }) + return + end + + if card_count > max_cards then + send_response({ + error = 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 + ), + error_code = BB_ERRORS.SCHEMA_INVALID_VALUE, + }) + 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 i, 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({ + error = "Consumable '" .. consumable_card.ability.name .. "' cannot be used at this time", + error_code = BB_ERRORS.GAME_INVALID_STATE, + }) + return + end + + -- Step 8: Space Check (not tested) + if consumable_card:check_use() then + send_response({ + error = "Cannot use consumable '" .. consumable_card.ability.name .. "': insufficient space", + error_code = BB_ERRORS.GAME_INVALID_STATE, + }) + return + end + + -- Execution + sendDebugMessage("Executing use() for consumable: " .. consumable_card.ability.name, "BB.ENDPOINTS") + + -- Track initial count for completion detection + local initial_consumable_count = G.consumeables.config.card_count + + -- 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: Card was removed + local card_removed = (G.consumeables.config.card_count < initial_consumable_count) + + -- Condition 2: State restored (not PLAY_TAROT anymore) + local state_restored = (G.STATE ~= G.STATES.PLAY_TAROT) + + -- Condition 3: Controller unlocked + local controller_unlocked = not G.CONTROLLER.locks.use + + -- Condition 4: no stop use + local no_stop_use = not (G.GAME.STOP_USE and G.GAME.STOP_USE > 0) + + if card_removed and state_restored and controller_unlocked and no_stop_use then + sendDebugMessage("use() completed successfully", "BB.ENDPOINTS") + send_response(BB_GAMESTATE.get_gamestate()) + return true + end + + return false + end, + })) + end, +} From 338fe757ab98a4148d2f24af9c2e2a857e4ed406 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Fri, 28 Nov 2025 14:50:33 +0100 Subject: [PATCH 156/230] test(fixtures): add fixture for `use` endpoint tests --- tests/fixtures/fixtures.json | 388 +++++++++++++++++++++++++++++++++++ 1 file changed, 388 insertions(+) diff --git a/tests/fixtures/fixtures.json b/tests/fixtures/fixtures.json index 7cb4daa..a294877 100644 --- a/tests/fixtures/fixtures.json +++ b/tests/fixtures/fixtures.json @@ -1471,5 +1471,393 @@ } } ] + }, + "use": { + "state-BLIND_SELECT": [ + { + "endpoint": "menu", + "arguments": {} + }, + { + "endpoint": "start", + "arguments": { + "deck": "RED", + "stake": "WHITE", + "seed": "TEST123" + } + } + ], + "state-ROUND_EVAL": [ + { + "endpoint": "menu", + "arguments": {} + }, + { + "endpoint": "start", + "arguments": { + "deck": "RED", + "stake": "WHITE", + "seed": "TEST123" + } + }, + { + "endpoint": "select", + "arguments": {} + }, + { + "endpoint": "set", + "arguments": { + "chips": 1000, + "money": 1000 + } + }, + { + "endpoint": "play", + "arguments": { + "cards": [ + 0 + ] + } + } + ], + "state-SELECTING_HAND--money-12--consumables.cards[0]-key-c_hermit": [ + { + "endpoint": "menu", + "arguments": {} + }, + { + "endpoint": "start", + "arguments": { + "deck": "RED", + "stake": "WHITE", + "seed": "TEST123" + } + }, + { + "endpoint": "select", + "arguments": {} + }, + { + "endpoint": "set", + "arguments": { + "money": 12 + } + }, + { + "endpoint": "add", + "arguments": { + "key": "c_hermit" + } + } + ], + "state-SELECTING_HAND--consumables.cards[0]-key-c_familiar": [ + { + "endpoint": "menu", + "arguments": {} + }, + { + "endpoint": "start", + "arguments": { + "deck": "RED", + "stake": "WHITE", + "seed": "TEST123" + } + }, + { + "endpoint": "select", + "arguments": {} + }, + { + "endpoint": "add", + "arguments": { + "key": "c_familiar" + } + } + ], + "state-SHOP--consumables.cards[0]-key-c_familiar": [ + { + "endpoint": "menu", + "arguments": {} + }, + { + "endpoint": "start", + "arguments": { + "deck": "RED", + "stake": "WHITE", + "seed": "TEST123" + } + }, + { + "endpoint": "select", + "arguments": {} + }, + { + "endpoint": "set", + "arguments": { + "chips": 1000 + } + }, + { + "endpoint": "play", + "arguments": { + "cards": [ + 0 + ] + } + }, + { + "endpoint": "cash_out", + "arguments": {} + }, + { + "endpoint": "add", + "arguments": { + "key": "c_familiar" + } + } + ], + "state-SELECTING_HAND--consumables.cards[0]-key-c_temperance--jokers.count-0": [ + { + "endpoint": "menu", + "arguments": {} + }, + { + "endpoint": "start", + "arguments": { + "deck": "RED", + "stake": "WHITE", + "seed": "TEST123" + } + }, + { + "endpoint": "select", + "arguments": {} + }, + { + "endpoint": "add", + "arguments": { + "key": "c_temperance" + } + } + ], + "state-SHOP--money-12--consumables.cards[0]-key-c_hermit": [ + { + "endpoint": "menu", + "arguments": {} + }, + { + "endpoint": "start", + "arguments": { + "deck": "RED", + "stake": "WHITE", + "seed": "TEST123" + } + }, + { + "endpoint": "select", + "arguments": {} + }, + { + "endpoint": "set", + "arguments": { + "chips": 1000 + } + }, + { + "endpoint": "play", + "arguments": { + "cards": [ + 0 + ] + } + }, + { + "endpoint": "cash_out", + "arguments": {} + }, + { + "endpoint": "set", + "arguments": { + "money": 12 + } + }, + { + "endpoint": "add", + "arguments": { + "key": "c_hermit" + } + } + ], + "state-SELECTING_HAND--consumables.cards[0].key-c_pluto--consumables.cards[1].key-c_magician": [ + { + "endpoint": "menu", + "arguments": {} + }, + { + "endpoint": "start", + "arguments": { + "deck": "RED", + "stake": "WHITE", + "seed": "TEST123" + } + }, + { + "endpoint": "select", + "arguments": {} + }, + { + "endpoint": "set", + "arguments": { + "chips": 1000, + "money": 1000 + } + }, + { + "endpoint": "play", + "arguments": { + "cards": [ + 0 + ] + } + }, + { + "endpoint": "cash_out", + "arguments": {} + }, + { + "endpoint": "buy", + "arguments": { + "card": 1 + } + }, + { + "endpoint": "reroll", + "arguments": {} + }, + { + "endpoint": "buy", + "arguments": { + "card": 1 + } + }, + { + "endpoint": "next_round", + "arguments": {} + }, + { + "endpoint": "select", + "arguments": {} + } + ], + "state-SELECTING_HAND--consumables.cards[0].key-c_death": [ + { + "endpoint": "menu", + "arguments": {} + }, + { + "endpoint": "start", + "arguments": { + "deck": "RED", + "stake": "WHITE", + "seed": "TEST123" + } + }, + { + "endpoint": "select", + "arguments": {} + }, + { + "endpoint": "add", + "arguments": { + "key": "c_death" + } + } + ], + "state-SHOP--consumables.cards[0].key-c_magician": [ + { + "endpoint": "menu", + "arguments": {} + }, + { + "endpoint": "start", + "arguments": { + "deck": "RED", + "stake": "WHITE", + "seed": "TEST123" + } + }, + { + "endpoint": "select", + "arguments": {} + }, + { + "endpoint": "set", + "arguments": { + "chips": 1000, + "money": 1000 + } + }, + { + "endpoint": "play", + "arguments": { + "cards": [ + 0 + ] + } + }, + { + "endpoint": "cash_out", + "arguments": {} + }, + { + "endpoint": "add", + "arguments": { + "key": "c_magician" + } + } + ], + "state-SHOP--consumables.cards[0].key-c_strength": [ + { + "endpoint": "menu", + "arguments": {} + }, + { + "endpoint": "start", + "arguments": { + "deck": "RED", + "stake": "WHITE", + "seed": "TEST123" + } + }, + { + "endpoint": "select", + "arguments": {} + }, + { + "endpoint": "set", + "arguments": { + "chips": 1000, + "money": 1000 + } + }, + { + "endpoint": "play", + "arguments": { + "cards": [ + 0 + ] + } + }, + { + "endpoint": "cash_out", + "arguments": {} + }, + { + "endpoint": "add", + "arguments": { + "key": "c_strength" + } + } + ] } } From a6f6a205d5e51c58aa78a8c36990817878372d12 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Fri, 28 Nov 2025 14:50:55 +0100 Subject: [PATCH 157/230] test(lua.endpoints): add test for `use` endpoint --- tests/lua/endpoints/test_use.py | 350 ++++++++++++++++++++++++++++++++ 1 file changed, 350 insertions(+) create mode 100644 tests/lua/endpoints/test_use.py diff --git a/tests/lua/endpoints/test_use.py b/tests/lua/endpoints/test_use.py new file mode 100644 index 0000000..80512ee --- /dev/null +++ b/tests/lua/endpoints/test_use.py @@ -0,0 +1,350 @@ +"""Tests for src/lua/endpoints/use.lua""" + +import socket + +from tests.lua.conftest import api, assert_error_response, load_fixture + + +class TestUseEndpoint: + """Test basic use endpoint functionality.""" + + def test_use_hermit_no_cards(self, client: socket.socket) -> 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 response["money"] == 12 * 2 + + def test_use_hermit_in_selecting_hand(self, client: socket.socket) -> 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 response["money"] == 12 * 2 + + def test_use_temperance_no_cards(self, client: socket.socket) -> None: + """Test using Temperance (no card selection).""" + gamestate = load_fixture( + client, + "use", + "state-SELECTING_HAND--consumables.cards[0]-key-c_temperance--jokers.count-0", + ) + assert gamestate["state"] == "SELECTING_HAND" + assert gamestate["jokers"]["count"] == 0 # no jokers => no money increase + assert gamestate["consumables"]["cards"][0]["key"] == "c_temperance" + response = api(client, "use", {"consumable": 0}) + assert response["money"] == gamestate["money"] + + def test_use_planet_no_cards(self, client: socket.socket) -> 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}) + assert response["hands"]["High Card"]["level"] == 2 + + def test_use_magician_with_one_card(self, client: socket.socket) -> 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]}) + assert response["hand"]["cards"][0]["modifier"]["enhancement"] == "LUCKY" + + def test_use_magician_with_two_cards(self, client: socket.socket) -> 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]}) + assert response["hand"]["cards"][5]["modifier"]["enhancement"] == "LUCKY" + assert response["hand"]["cards"][7]["modifier"]["enhancement"] == "LUCKY" + + def test_use_familiar_all_hand(self, client: socket.socket) -> None: + """Test using Familiar (destroys cards, #G.hand.cards > 1).""" + gamestate = load_fixture( + client, + "use", + "state-SELECTING_HAND--consumables.cards[0]-key-c_familiar", + ) + assert gamestate["state"] == "SELECTING_HAND" + response = api(client, "use", {"consumable": 0}) + assert response["hand"]["count"] == gamestate["hand"]["count"] - 1 + 3 + assert response["hand"]["cards"][7]["set"] == "ENHANCED" + assert response["hand"]["cards"][8]["set"] == "ENHANCED" + assert response["hand"]["cards"][9]["set"] == "ENHANCED" + + +class TestUseEndpointValidation: + """Test use endpoint parameter validation.""" + + def test_use_no_consumable_provided(self, client: socket.socket) -> 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", {}), + "SCHEMA_MISSING_REQUIRED", + "Missing required field 'consumable'", + ) + + def test_use_invalid_consumable_type(self, client: socket.socket) -> 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"}), + "SCHEMA_INVALID_TYPE", + "Field 'consumable' must be an integer", + ) + + def test_use_invalid_consumable_index_negative(self, client: socket.socket) -> 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}), + "SCHEMA_INVALID_VALUE", + "Consumable index out of range: -1", + ) + + def test_use_invalid_consumable_index_too_high(self, client: socket.socket) -> 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}), + "SCHEMA_INVALID_VALUE", + "Consumable index out of range: 999", + ) + + def test_use_invalid_cards_type(self, client: socket.socket) -> 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"}), + "SCHEMA_INVALID_TYPE", + "Field 'cards' must be an array", + ) + + def test_use_invalid_cards_item_type(self, client: socket.socket) -> 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"]}), + "SCHEMA_INVALID_ARRAY_ITEMS", + "Field 'cards' array item at index 0 must be of type integer", + ) + + def test_use_invalid_card_index_negative(self, client: socket.socket) -> 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]}), + "SCHEMA_INVALID_VALUE", + "Card index out of range: -1", + ) + + def test_use_invalid_card_index_too_high(self, client: socket.socket) -> 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]}), + "SCHEMA_INVALID_VALUE", + "Card index out of range: 999", + ) + + def test_use_magician_without_cards(self, client: socket.socket) -> 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}), + "SCHEMA_MISSING_REQUIRED", + "Consumable 'The Magician' requires card selection", + ) + + def test_use_magician_with_empty_cards(self, client: socket.socket) -> 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": []}), + "SCHEMA_MISSING_REQUIRED", + "Consumable 'The Magician' requires card selection", + ) + + def test_use_magician_too_many_cards(self, client: socket.socket) -> 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]}), + "SCHEMA_INVALID_VALUE", + "Consumable 'The Magician' requires at most 2 cards (provided: 3)", + ) + + def test_use_death_too_few_cards(self, client: socket.socket) -> 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]}), + "SCHEMA_INVALID_VALUE", + "Consumable 'Death' requires exactly 2 cards (provided: 1)", + ) + + def test_use_death_too_many_cards(self, client: socket.socket) -> 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]}), + "SCHEMA_INVALID_VALUE", + "Consumable 'Death' requires exactly 2 cards (provided: 3)", + ) + + +class TestUseEndpointStateRequirements: + """Test use endpoint state requirements.""" + + def test_use_from_BLIND_SELECT(self, client: socket.socket) -> 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]}), + "STATE_INVALID_STATE", + "Endpoint 'use' requires one of these states: 1, 5", + ) + + def test_use_from_ROUND_EVAL(self, client: socket.socket) -> 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]}), + "STATE_INVALID_STATE", + "Endpoint 'use' requires one of these states: 1, 5", + ) + + def test_use_magician_from_SHOP(self, client: socket.socket) -> 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]}), + "STATE_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: socket.socket) -> 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}), + "GAME_INVALID_STATE", + "Consumable 'Familiar' cannot be used at this time", + ) From abbec8375086fa6e6fdcc6b15983542c365db750 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Fri, 28 Nov 2025 15:23:41 +0100 Subject: [PATCH 158/230] feat(lua.utils): add BB_API global namespace for calling endpoints via /eval --- src/lua/utils/debugger.lua | 102 +++++++++++++++++++++++++++++++++++-- 1 file changed, 99 insertions(+), 3 deletions(-) diff --git a/src/lua/utils/debugger.lua b/src/lua/utils/debugger.lua index 0cf20fb..f86166b 100644 --- a/src/lua/utils/debugger.lua +++ b/src/lua/utils/debugger.lua @@ -11,6 +11,104 @@ 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, +}) + BB_DEBUG = { -- Logger instance (set by setup if DebugPlus is available) ---@type table? @@ -38,7 +136,5 @@ BB_DEBUG.setup = function() -- Create a logger BB_DEBUG.log = dp.logger BB_DEBUG.log.debug("DebugPlus API available") - - -- Register commands - -- ... + BB_DEBUG.log.info("Use /eval BB_API.endpoint_name({args}) to call API endpoints") end From 9099333aa0bedba6b707bef21b6cb0937990377e Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Fri, 28 Nov 2025 15:24:25 +0100 Subject: [PATCH 159/230] chore: improve startup time by move to workspace 3 after 1 sec --- balatro.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/balatro.py b/balatro.py index a02a615..3028af6 100755 --- a/balatro.py +++ b/balatro.py @@ -140,17 +140,19 @@ def start(args): stderr=subprocess.STDOUT, ) - # Verify it started - time.sleep(4) - if process.poll() is not None: - print(f"ERROR: Balatro failed to start. Check {log_file}") - sys.exit(1) - + time.sleep(1) + # Move back to workspace 3 (code editor) subprocess.Popen( "command -v aerospace >/dev/null 2>&1 && aerospace workspace 3", shell=True, ) + # Verify it started + time.sleep(3) + if process.poll() is not None: + print(f"ERROR: Balatro failed to start. Check {log_file}") + sys.exit(1) + print(f"Port {args.port}, PID {process.pid}, Log: {log_file}") From ffefcd23eda828784b8b0974807d26d6ace96f5b Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Fri, 28 Nov 2025 18:26:03 +0100 Subject: [PATCH 160/230] docs(lua.utils): be more explicit about supported modifiers --- src/lua/utils/types.lua | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/lua/utils/types.lua b/src/lua/utils/types.lua index 36e97a4..17498a8 100644 --- a/src/lua/utils/types.lua +++ b/src/lua/utils/types.lua @@ -73,12 +73,12 @@ ---@field effect string Description of the card's effect (from UI) ---@class Card.Modifier ----@field seal Card.Modifier.Seal? Seal type ----@field edition Card.Modifier.Edition? Edition type ----@field enhancement Card.Modifier.Enhancement? Enhancement type ----@field eternal boolean? If true, card cannot be sold or destroyed ----@field perishable integer? Number of rounds remaining (only if > 0) ----@field rental boolean? If true, card costs money at end of round +---@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 From 01eff369e6f2bcab900dd54a17a9f43c168873cd Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Sat, 29 Nov 2025 17:02:39 +0100 Subject: [PATCH 161/230] feat(lua.endpoints): add support for modifiers in the `add` endpoint --- src/lua/endpoints/add.lua | 221 +++++++++++++++++++++++++++++++++++++- 1 file changed, 220 insertions(+), 1 deletion(-) diff --git a/src/lua/endpoints/add.lua b/src/lua/endpoints/add.lua index 3581bc1..026b85f 100644 --- a/src/lua/endpoints/add.lua +++ b/src/lua/endpoints/add.lua @@ -5,6 +5,12 @@ ---@class Endpoint.Add.Args ---@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) -- Suit conversion table for playing cards local SUIT_MAP = { @@ -31,6 +37,34 @@ local RANK_MAP = { A = "Ace", } +-- Seal conversion table +local SEAL_MAP = { + RED = "Red", + BLUE = "Blue", + GOLD = "Gold", + PURPLE = "Purple", +} + +-- Edition conversion table +local EDITION_MAP = { + HOLO = "e_holo", + FOIL = "e_foil", + POLYCHROME = "e_polychrome", + NEGATIVE = "e_negative", +} + +-- Enhancement conversion table +local ENHANCEMENT_MAP = { + BONUS = "m_bonus", + MULT = "m_mult", + WILD = "m_wild", + GLASS = "m_glass", + STEEL = "m_steel", + STONE = "m_stone", + GOLD = "m_gold", + LUCKY = "m_lucky", +} + ---Detect card type based on key prefix or pattern ---@param key string The card key ---@return string|nil card_type The detected card type or nil if invalid @@ -81,6 +115,36 @@ return { 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 = "number", + 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 }, @@ -118,6 +182,119 @@ return { return end + -- Validate seal parameter is only for playing cards + if args.seal and card_type ~= "playing_card" then + send_response({ + error = "Seal can only be applied to playing cards", + error_code = BB_ERRORS.SCHEMA_INVALID_VALUE, + }) + 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({ + error = "Invalid seal value. Expected: RED, BLUE, GOLD, or PURPLE", + error_code = BB_ERRORS.SCHEMA_INVALID_VALUE, + }) + return + end + end + + -- Validate edition parameter is only for jokers, playing cards, or consumables + if args.edition and card_type == "voucher" then + send_response({ + error = "Edition cannot be applied to vouchers", + error_code = BB_ERRORS.SCHEMA_INVALID_VALUE, + }) + return + end + + -- Special validation: consumables can only have NEGATIVE edition + if args.edition and card_type == "consumable" and args.edition ~= "NEGATIVE" then + send_response({ + error = "Consumables can only have NEGATIVE edition", + error_code = BB_ERRORS.SCHEMA_INVALID_VALUE, + }) + 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({ + error = "Invalid edition value. Expected: HOLO, FOIL, POLYCHROME, or NEGATIVE", + error_code = BB_ERRORS.SCHEMA_INVALID_VALUE, + }) + return + end + end + + -- Validate enhancement parameter is only for playing cards + if args.enhancement and card_type ~= "playing_card" then + send_response({ + error = "Enhancement can only be applied to playing cards", + error_code = BB_ERRORS.SCHEMA_INVALID_VALUE, + }) + 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({ + error = "Invalid enhancement value. Expected: BONUS, MULT, WILD, GLASS, STEEL, STONE, GOLD, or LUCKY", + error_code = BB_ERRORS.SCHEMA_INVALID_VALUE, + }) + return + end + end + + -- Validate eternal parameter is only for jokers + if args.eternal and card_type ~= "joker" then + send_response({ + error = "Eternal can only be applied to jokers", + error_code = BB_ERRORS.SCHEMA_INVALID_VALUE, + }) + return + end + + -- Validate perishable parameter is only for jokers + if args.perishable and card_type ~= "joker" then + send_response({ + error = "Perishable can only be applied to jokers", + error_code = BB_ERRORS.SCHEMA_INVALID_VALUE, + }) + 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({ + error = "Perishable must be a positive integer (>= 1)", + error_code = BB_ERRORS.SCHEMA_INVALID_VALUE, + }) + return + end + end + + -- Validate rental parameter is only for jokers + if args.rental and card_type ~= "joker" then + send_response({ + error = "Rental can only be applied to jokers", + error_code = BB_ERRORS.SCHEMA_INVALID_VALUE, + }) + return + end + -- Build SMODS.add_card parameters based on card type local params @@ -129,6 +306,21 @@ return { 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, @@ -140,7 +332,29 @@ return { 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 @@ -152,7 +366,7 @@ return { sendDebugMessage("Initial voucher count: " .. initial_voucher_count, "BB.ENDPOINTS") -- Call SMODS.add_card with error handling - local success, _ = pcall(SMODS.add_card, params) + local success, result = pcall(SMODS.add_card, params) if not success then send_response({ @@ -162,6 +376,11 @@ return { 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 From 97413ab56f6d265e06ad2e3fd5816f4b9c5970ce Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Sat, 29 Nov 2025 17:03:40 +0100 Subject: [PATCH 162/230] test(lua.endpoints): add test for modifier params for `add` endpoint --- tests/lua/endpoints/test_add.py | 435 ++++++++++++++++++++++++++++++++ 1 file changed, 435 insertions(+) diff --git a/tests/lua/endpoints/test_add.py b/tests/lua/endpoints/test_add.py index 5b250ea..6d06841 100644 --- a/tests/lua/endpoints/test_add.py +++ b/tests/lua/endpoints/test_add.py @@ -2,6 +2,8 @@ import socket +import pytest + from tests.lua.conftest import api, assert_error_response, load_fixture @@ -187,3 +189,436 @@ def test_add_voucher_card_from_SELECTING_HAND(self, client: socket.socket) -> No "STATE_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: socket.socket, 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}) + assert response["hand"]["count"] == 9 + assert response["hand"]["cards"][8]["key"] == "H_A" + assert response["hand"]["cards"][8]["modifier"]["seal"] == seal + + def test_add_playing_card_invalid_seal(self, client: socket.socket) -> 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, + "SCHEMA_INVALID_VALUE", + "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: socket.socket, 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, + "SCHEMA_INVALID_VALUE", + "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: socket.socket, 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}) + assert response["jokers"]["count"] == 1 + assert response["jokers"]["cards"][0]["key"] == "j_joker" + assert response["jokers"]["cards"][0]["modifier"]["edition"] == edition + + @pytest.mark.parametrize("edition", ["HOLO", "FOIL", "POLYCHROME", "NEGATIVE"]) + def test_add_playing_card_with_edition( + self, client: socket.socket, 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}) + assert response["hand"]["count"] == 9 + assert response["hand"]["cards"][8]["key"] == "H_A" + assert response["hand"]["cards"][8]["modifier"]["edition"] == edition + + def test_add_consumable_with_negative_edition(self, client: socket.socket) -> 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"}) + assert response["consumables"]["count"] == 1 + assert response["consumables"]["cards"][0]["key"] == "c_fool" + assert response["consumables"]["cards"][0]["modifier"]["edition"] == "NEGATIVE" + + @pytest.mark.parametrize("edition", ["HOLO", "FOIL", "POLYCHROME"]) + def test_add_consumable_with_non_negative_edition_fails( + self, client: socket.socket, 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, + "SCHEMA_INVALID_VALUE", + "Consumables can only have NEGATIVE edition", + ) + + def test_add_voucher_with_edition_fails(self, client: socket.socket) -> 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, "SCHEMA_INVALID_VALUE", "Edition cannot be applied to vouchers" + ) + + def test_add_playing_card_invalid_edition(self, client: socket.socket) -> 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, + "SCHEMA_INVALID_VALUE", + "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: socket.socket, 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}) + assert response["hand"]["count"] == 9 + assert response["hand"]["cards"][8]["key"] == "H_A" + assert response["hand"]["cards"][8]["modifier"]["enhancement"] == enhancement + + def test_add_playing_card_invalid_enhancement(self, client: socket.socket) -> 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, + "SCHEMA_INVALID_VALUE", + "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: socket.socket, 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, + "SCHEMA_INVALID_VALUE", + "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: socket.socket) -> 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}) + assert response["jokers"]["count"] == 1 + assert response["jokers"]["cards"][0]["key"] == "j_joker" + assert response["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: socket.socket, 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}), + "SCHEMA_INVALID_VALUE", + "Eternal can only be applied to jokers", + ) + + def test_add_playing_card_with_eternal_fails(self, client: socket.socket) -> 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}), + "SCHEMA_INVALID_VALUE", + "Eternal can only be applied to jokers", + ) + + @pytest.mark.parametrize("rounds", [1, 5, 10]) + def test_add_joker_with_perishable( + self, client: socket.socket, 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}) + assert response["jokers"]["count"] == 1 + assert response["jokers"]["cards"][0]["key"] == "j_joker" + assert response["jokers"]["cards"][0]["modifier"]["perishable"] == rounds + + def test_add_joker_with_eternal_and_perishable(self, client: socket.socket) -> 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} + ) + assert response["jokers"]["count"] == 1 + assert response["jokers"]["cards"][0]["key"] == "j_joker" + assert response["jokers"]["cards"][0]["modifier"]["eternal"] is True + assert response["jokers"]["cards"][0]["modifier"]["perishable"] == 5 + + @pytest.mark.parametrize("invalid_value", [0, -1, 1.5]) + def test_add_joker_with_perishable_invalid_value_fails( + self, client: socket.socket, invalid_value: int | float | str + ) -> 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, + "SCHEMA_INVALID_VALUE", + "Perishable must be a positive integer (>= 1)", + ) + + def test_add_joker_with_perishable_string_fails( + self, client: socket.socket + ) -> 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": "NOT_INT_1"}) + assert_error_response( + response, + "SCHEMA_INVALID_TYPE", + "Field 'perishable' must be of type number", + ) + + @pytest.mark.parametrize("key", ["c_fool", "v_overstock_norm"]) + def test_add_non_joker_with_perishable_fails( + self, client: socket.socket, 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, + "SCHEMA_INVALID_VALUE", + "Perishable can only be applied to jokers", + ) + + def test_add_playing_card_with_perishable_fails( + self, client: socket.socket + ) -> 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, + "SCHEMA_INVALID_VALUE", + "Perishable can only be applied to jokers", + ) + + def test_add_joker_with_rental(self, client: socket.socket) -> 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}) + assert response["jokers"]["count"] == 1 + assert response["jokers"]["cards"][0]["key"] == "j_joker" + assert response["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: socket.socket, 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}), + "SCHEMA_INVALID_VALUE", + "Rental can only be applied to jokers", + ) + + def test_add_joker_with_rental_and_eternal(self, client: socket.socket) -> 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} + ) + assert response["jokers"]["count"] == 1 + assert response["jokers"]["cards"][0]["key"] == "j_joker" + assert response["jokers"]["cards"][0]["modifier"]["rental"] is True + assert response["jokers"]["cards"][0]["modifier"]["eternal"] is True + + def test_add_playing_card_with_rental_fails(self, client: socket.socket) -> 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}), + "SCHEMA_INVALID_VALUE", + "Rental can only be applied to jokers", + ) From 21f47c9fcbe419d93921fec357d3c32ec275e844 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Sat, 29 Nov 2025 17:04:33 +0100 Subject: [PATCH 163/230] docs(lua.utils): add missing descriptions to enums --- src/lua/utils/enums.lua | 106 +++++++++++++++++++--------------------- 1 file changed, 51 insertions(+), 55 deletions(-) diff --git a/src/lua/utils/enums.lua b/src/lua/utils/enums.lua index 1aa43b0..6ff1c59 100644 --- a/src/lua/utils/enums.lua +++ b/src/lua/utils/enums.lua @@ -1,9 +1,5 @@ ---@meta enums --- ========================================================================== --- GameState Enums --- ========================================================================== - ---@alias Deck ---| "RED" # +1 discard every round ---| "BLUE" # +1 hand every round @@ -51,40 +47,40 @@ ---| "STANDARD_PACK" # 17 ---| "BUFFOON_PACK" # 18 ---| "NEW_ROUND" # 19 When a round is won and the new round begins ----| "SMODS_BOOSTER_OPENED" # 999 +---| "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" ----| "DEFAULT" ----| "EDITION" ----| "ENHANCED" ----| "JOKER" ----| "TAROT" ----| "PLANET" ----| "SPECTRAL" ----| "VOUCHER" +---| "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 ----| "D" # Diamonds ----| "C" # Clubs ----| "S" # Spades +---| "H" # Hearts (playing card) +---| "D" # Diamonds (playing card) +---| "C" # Clubs (playing card) +---| "S" # Spades (playing card) ---@alias Card.Value.Rank ----| "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 +---| "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) @@ -348,35 +344,35 @@ ---| Card.Key.PlayingCard ---@alias Card.Modifier.Seal ----| "RED" ----| "BLUE" ----| "GOLD" ----| "PURPLE" +---| "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" ----| "FOIL" ----| "POLYCHROME" ----| "NEGATIVE" +---| "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" ----| "MULT" ----| "WILD" ----| "GLASS" ----| "STEEL" ----| "STONE" ----| "GOLD" ----| "LUCKY" +---| "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" ----| "BIG" ----| "BOSS" +---| "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" ----| "CURRENT" ----| "UPCOMING" ----| "DEFEATED" ----| "SKIPPED" +---| "SELECT" # Selectable blind +---| "CURRENT" # Current blind selected +---| "UPCOMING" # Future blind +---| "DEFEATED" # Previously defeated blind +---| "SKIPPED" # Previously skipped blind From 6de65f484de60bf28f3f50d7d5b3469da0d95f17 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Sat, 29 Nov 2025 17:38:04 +0100 Subject: [PATCH 164/230] feat(lua.core): add state name lookup table --- src/lua/core/dispatcher.lua | 34 ++++++++++++++++++++++++++++++---- 1 file changed, 30 insertions(+), 4 deletions(-) diff --git a/src/lua/core/dispatcher.lua b/src/lua/core/dispatcher.lua index 348fb75..4e9514f 100644 --- a/src/lua/core/dispatcher.lua +++ b/src/lua/core/dispatcher.lua @@ -21,6 +21,29 @@ local Validator = assert(SMODS.load_file("src/lua/core/validator.lua"))() ---@type ErrorCodes local errors = assert(SMODS.load_file("src/lua/utils/errors.lua"))() +-- State name lookup cache (built lazily from G.STATES) +---@type table? +local STATE_NAME_CACHE = nil + +--- Get the name of a state from its numeric value +--- Builds a reverse mapping from G.STATES on first call +---@param state_value number The numeric state value +---@return string state_name The state name (or stringified number if not found) +local function get_state_name(state_value) + -- Build cache on first use + 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 + + -- Look up the name, fall back to stringified number + return STATE_NAME_CACHE[state_value] or tostring(state_value) +end + ---@class Dispatcher ---@field endpoints table Endpoint registry mapping names to modules ---@field Server table? Reference to Server module for sending responses @@ -224,11 +247,14 @@ function BB_DISPATCHER.dispatch(request) end if not state_valid then + -- Convert state numbers to names for the error message + local state_names = {} + for _, state in ipairs(endpoint.requires_state) do + table.insert(state_names, get_state_name(state)) + end + BB_DISPATCHER.send_error( - "Endpoint '" - .. request.name - .. "' requires one of these states: " - .. table.concat(endpoint.requires_state, ", "), + "Endpoint '" .. request.name .. "' requires one of these states: " .. table.concat(state_names, ", "), errors.STATE_INVALID_STATE ) return From c37a4b640b4f3f7e90c52e24dcd92ca2b1c9f877 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Sat, 29 Nov 2025 17:38:50 +0100 Subject: [PATCH 165/230] test(lua.endpoints): update test messages with new error messages --- tests/lua/endpoints/test_add.py | 2 +- tests/lua/endpoints/test_buy.py | 2 +- tests/lua/endpoints/test_cash_out.py | 2 +- tests/lua/endpoints/test_discard.py | 2 +- tests/lua/endpoints/test_next_round.py | 2 +- tests/lua/endpoints/test_play.py | 2 +- tests/lua/endpoints/test_rearrange.py | 6 +++--- tests/lua/endpoints/test_reroll.py | 2 +- tests/lua/endpoints/test_save.py | 2 +- tests/lua/endpoints/test_select.py | 2 +- tests/lua/endpoints/test_sell.py | 4 ++-- tests/lua/endpoints/test_skip.py | 2 +- tests/lua/endpoints/test_start.py | 2 +- tests/lua/endpoints/test_use.py | 4 ++-- 14 files changed, 18 insertions(+), 18 deletions(-) diff --git a/tests/lua/endpoints/test_add.py b/tests/lua/endpoints/test_add.py index 6d06841..7b846af 100644 --- a/tests/lua/endpoints/test_add.py +++ b/tests/lua/endpoints/test_add.py @@ -159,7 +159,7 @@ def test_add_from_BLIND_SELECT(self, client: socket.socket) -> None: assert_error_response( api(client, "add", {"key": "j_joker"}), "STATE_INVALID_STATE", - "Endpoint 'add' requires one of these states: 1, 5, 8", + "Endpoint 'add' requires one of these states: SELECTING_HAND, SHOP, ROUND_EVAL", ) def test_add_playing_card_from_SHOP(self, client: socket.socket) -> None: diff --git a/tests/lua/endpoints/test_buy.py b/tests/lua/endpoints/test_buy.py index 0edaecb..b2df6d6 100644 --- a/tests/lua/endpoints/test_buy.py +++ b/tests/lua/endpoints/test_buy.py @@ -211,5 +211,5 @@ def test_buy_from_BLIND_SELECT(self, client: socket.socket) -> None: assert_error_response( api(client, "buy", {"card": 0}), "STATE_INVALID_STATE", - "Endpoint 'buy' requires one of these states:", + "Endpoint '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 0194bce..2052d75 100644 --- a/tests/lua/endpoints/test_cash_out.py +++ b/tests/lua/endpoints/test_cash_out.py @@ -40,5 +40,5 @@ def test_cash_out_from_BLIND_SELECT(self, client: socket.socket): assert_error_response( api(client, "cash_out", {}), "STATE_INVALID_STATE", - "Endpoint 'cash_out' requires one of these states:", + "Endpoint '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 index 3879d8a..9d2b552 100644 --- a/tests/lua/endpoints/test_discard.py +++ b/tests/lua/endpoints/test_discard.py @@ -108,5 +108,5 @@ def test_discard_from_BLIND_SELECT(self, client: socket.socket): assert_error_response( api(client, "discard", {"cards": [0]}), "STATE_INVALID_STATE", - "Endpoint 'discard' requires one of these states:", + "Endpoint 'discard' requires one of these states: SELECTING_HAND", ) diff --git a/tests/lua/endpoints/test_next_round.py b/tests/lua/endpoints/test_next_round.py index 16f72c2..dc2f366 100644 --- a/tests/lua/endpoints/test_next_round.py +++ b/tests/lua/endpoints/test_next_round.py @@ -40,5 +40,5 @@ def test_next_round_from_MENU(self, client: socket.socket): assert_error_response( response, expected_error_code="STATE_INVALID_STATE", - expected_message_contains="Endpoint 'next_round' requires one of these states:", + expected_message_contains="Endpoint 'next_round' requires one of these states: SHOP", ) diff --git a/tests/lua/endpoints/test_play.py b/tests/lua/endpoints/test_play.py index 322b8a8..9fb11a4 100644 --- a/tests/lua/endpoints/test_play.py +++ b/tests/lua/endpoints/test_play.py @@ -116,5 +116,5 @@ def test_play_from_BLIND_SELECT(self, client: socket.socket): assert_error_response( api(client, "play", {"cards": [0]}), "STATE_INVALID_STATE", - "Endpoint 'play' requires one of these states:", + "Endpoint 'play' requires one of these states: SELECTING_HAND", ) diff --git a/tests/lua/endpoints/test_rearrange.py b/tests/lua/endpoints/test_rearrange.py index ae88a56..c1b29a9 100644 --- a/tests/lua/endpoints/test_rearrange.py +++ b/tests/lua/endpoints/test_rearrange.py @@ -192,7 +192,7 @@ def test_rearrange_hand_from_wrong_state(self, client: socket.socket) -> None: assert_error_response( api(client, "rearrange", {"hand": [0, 1, 2, 3, 4, 5, 6, 7]}), "STATE_INVALID_STATE", - "Endpoint 'rearrange' requires one of these states: 1, 5", + "Endpoint 'rearrange' requires one of these states: SELECTING_HAND, SHOP", ) def test_rearrange_jokers_from_wrong_state(self, client: socket.socket) -> None: @@ -202,7 +202,7 @@ def test_rearrange_jokers_from_wrong_state(self, client: socket.socket) -> None: assert_error_response( api(client, "rearrange", {"jokers": [0, 1, 2, 3, 4]}), "STATE_INVALID_STATE", - "Endpoint 'rearrange' requires one of these states: 1, 5", + "Endpoint 'rearrange' requires one of these states: SELECTING_HAND, SHOP", ) def test_rearrange_consumables_from_wrong_state( @@ -214,7 +214,7 @@ def test_rearrange_consumables_from_wrong_state( assert_error_response( api(client, "rearrange", {"jokers": [0, 1]}), "STATE_INVALID_STATE", - "Endpoint 'rearrange' requires one of these states: 1, 5", + "Endpoint 'rearrange' requires one of these states: SELECTING_HAND, SHOP", ) def test_rearrange_hand_from_shop(self, client: socket.socket) -> None: diff --git a/tests/lua/endpoints/test_reroll.py b/tests/lua/endpoints/test_reroll.py index 2aef5d2..01e869a 100644 --- a/tests/lua/endpoints/test_reroll.py +++ b/tests/lua/endpoints/test_reroll.py @@ -39,5 +39,5 @@ def test_reroll_from_BLIND_SELECT(self, client: socket.socket): assert_error_response( api(client, "reroll", {}), "STATE_INVALID_STATE", - "Endpoint 'reroll' requires one of these states:", + "Endpoint 'reroll' requires one of these states: SHOP", ) diff --git a/tests/lua/endpoints/test_save.py b/tests/lua/endpoints/test_save.py index dbfca20..d69130c 100644 --- a/tests/lua/endpoints/test_save.py +++ b/tests/lua/endpoints/test_save.py @@ -73,6 +73,6 @@ def test_save_from_MENU(self, client: socket.socket, tmp_path: Path) -> None: assert_error_response( response, expected_error_code="STATE_INVALID_STATE", - expected_message_contains="requires one of these states", + expected_message_contains="Endpoint '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_select.py b/tests/lua/endpoints/test_select.py index 57b8f4d..6fc9464 100644 --- a/tests/lua/endpoints/test_select.py +++ b/tests/lua/endpoints/test_select.py @@ -65,5 +65,5 @@ def test_select_from_MENU(self, client: socket.socket): assert_error_response( api(client, "select", {}), "STATE_INVALID_STATE", - "Endpoint 'select' requires one of these states:", + "Endpoint 'select' requires one of these states: BLIND_SELECT", ) diff --git a/tests/lua/endpoints/test_sell.py b/tests/lua/endpoints/test_sell.py index 709dce1..842f1a6 100644 --- a/tests/lua/endpoints/test_sell.py +++ b/tests/lua/endpoints/test_sell.py @@ -171,7 +171,7 @@ def test_sell_from_BLIND_SELECT(self, client: socket.socket) -> None: assert_error_response( api(client, "sell", {}), "STATE_INVALID_STATE", - "Endpoint 'sell' requires one of these states: 1, 5", + "Endpoint 'sell' requires one of these states: SELECTING_HAND, SHOP", ) def test_sell_from_ROUND_EVAL(self, client: socket.socket) -> None: @@ -181,5 +181,5 @@ def test_sell_from_ROUND_EVAL(self, client: socket.socket) -> None: assert_error_response( api(client, "sell", {}), "STATE_INVALID_STATE", - "Endpoint 'sell' requires one of these states: 1, 5", + "Endpoint 'sell' requires one of these states: SELECTING_HAND, SHOP", ) diff --git a/tests/lua/endpoints/test_skip.py b/tests/lua/endpoints/test_skip.py index 028e86d..b1bbe56 100644 --- a/tests/lua/endpoints/test_skip.py +++ b/tests/lua/endpoints/test_skip.py @@ -69,5 +69,5 @@ def test_skip_from_MENU(self, client: socket.socket): assert_error_response( api(client, "skip", {}), "STATE_INVALID_STATE", - "Endpoint 'skip' requires one of these states:", + "Endpoint 'skip' requires one of these states: BLIND_SELECT", ) diff --git a/tests/lua/endpoints/test_start.py b/tests/lua/endpoints/test_start.py index a6a2635..6f04975 100644 --- a/tests/lua/endpoints/test_start.py +++ b/tests/lua/endpoints/test_start.py @@ -156,5 +156,5 @@ def test_start_from_BLIND_SELECT(self, client: socket.socket): assert_error_response( response, expected_error_code="STATE_INVALID_STATE", - expected_message_contains="Endpoint 'start' requires one of these states:", + expected_message_contains="Endpoint 'start' requires one of these states: MENU", ) diff --git a/tests/lua/endpoints/test_use.py b/tests/lua/endpoints/test_use.py index 80512ee..b31aba5 100644 --- a/tests/lua/endpoints/test_use.py +++ b/tests/lua/endpoints/test_use.py @@ -302,7 +302,7 @@ def test_use_from_BLIND_SELECT(self, client: socket.socket) -> None: assert_error_response( api(client, "use", {"consumable": 0, "cards": [0]}), "STATE_INVALID_STATE", - "Endpoint 'use' requires one of these states: 1, 5", + "Endpoint 'use' requires one of these states: SELECTING_HAND, SHOP", ) def test_use_from_ROUND_EVAL(self, client: socket.socket) -> None: @@ -316,7 +316,7 @@ def test_use_from_ROUND_EVAL(self, client: socket.socket) -> None: assert_error_response( api(client, "use", {"consumable": 0, "cards": [0]}), "STATE_INVALID_STATE", - "Endpoint 'use' requires one of these states: 1, 5", + "Endpoint 'use' requires one of these states: SELECTING_HAND, SHOP", ) def test_use_magician_from_SHOP(self, client: socket.socket) -> None: From e7db3f593f6a91c4ad4e01d503ddda0c1171141e Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Sun, 30 Nov 2025 12:17:48 +0100 Subject: [PATCH 166/230] feat(lua.utils): new simplify error codes --- src/lua/utils/errors.lua | 81 +++++----------------------------------- 1 file changed, 9 insertions(+), 72 deletions(-) diff --git a/src/lua/utils/errors.lua b/src/lua/utils/errors.lua index 2b06618..8155d93 100644 --- a/src/lua/utils/errors.lua +++ b/src/lua/utils/errors.lua @@ -1,79 +1,16 @@ -- src/lua/utils/errors.lua --- Semantic Error Codes with Category Prefixes --- --- Error codes are organized by category for easier handling and debugging: --- - PROTO_* : Protocol-level errors (malformed requests) --- - SCHEMA_* : Schema validation errors (argument type/constraint errors) --- - STATE_* : Game state validation errors (wrong state for action) --- - GAME_* : Game logic errors (game rules violations) --- - SEMANTIC_* : Endpoint-specific semantic errors --- - EXEC_* : Execution errors (runtime failures) +-- Error Codes for BalatroBot API ---@class ErrorCodes ----@field PROTO_INVALID_JSON string ----@field PROTO_MISSING_NAME string ----@field PROTO_MISSING_ARGUMENTS string ----@field PROTO_UNKNOWN_ENDPOINT string ----@field PROTO_PAYLOAD string ----@field SCHEMA_INVALID_TYPE string ----@field SCHEMA_MISSING_REQUIRED string ----@field SCHEMA_INVALID_ARRAY_ITEMS string ----@field SCHEMA_INVALID_VALUE string ----@field STATE_INVALID_STATE string ----@field STATE_NOT_READY string ----@field GAME_NOT_IN_RUN string ----@field GAME_INVALID_STATE string ----@field EXEC_INTERNAL_ERROR string ----@field EXEC_FILE_NOT_FOUND string ----@field EXEC_FILE_READ_ERROR string ----@field EXEC_FILE_WRITE_ERROR string ----@field EXEC_INVALID_SAVE_FORMAT string +---@field BAD_REQUEST string Client sent invalid data (protocol/parameter errors) +---@field INVALID_STATE string Action not allowed in current game state +---@field NOT_ALLOWED string Game rules prevent this action +---@field INTERNAL_ERROR string Server-side failure (runtime/execution errors) ---@type ErrorCodes return { - -- PROTO_* : Protocol-level errors (malformed requests) - PROTO_INVALID_JSON = "PROTO_INVALID_JSON", -- Invalid JSON syntax or non-object - PROTO_MISSING_NAME = "PROTO_MISSING_NAME", -- Request missing 'name' field - PROTO_MISSING_ARGUMENTS = "PROTO_MISSING_ARGUMENTS", -- Request missing 'arguments' field - PROTO_UNKNOWN_ENDPOINT = "PROTO_UNKNOWN_ENDPOINT", -- Unknown endpoint name - PROTO_PAYLOAD = "PROTO_PAYLOAD", -- Request exceeds 256 byte limit - - -- SCHEMA_* : Schema validation errors (argument type/constraint errors) - SCHEMA_INVALID_TYPE = "SCHEMA_INVALID_TYPE", -- Argument type mismatch - SCHEMA_MISSING_REQUIRED = "SCHEMA_MISSING_REQUIRED", -- Required argument missing - SCHEMA_INVALID_ARRAY_ITEMS = "SCHEMA_INVALID_ARRAY_ITEMS", -- Invalid array item type - SCHEMA_INVALID_VALUE = "SCHEMA_INVALID_VALUE", -- Argument value out of range or invalid - - -- STATE_* : Game state validation errors (wrong state for action) - STATE_INVALID_STATE = "STATE_INVALID_STATE", -- Action not allowed in current state - STATE_NOT_READY = "STATE_NOT_READY", -- Server/dispatcher not initialized - - -- GAME_* : Game logic errors (game rules violations) - GAME_NOT_IN_RUN = "GAME_NOT_IN_RUN", -- Action requires active run - GAME_INVALID_STATE = "GAME_INVALID_STATE", -- Action not allowed in current game state - - -- EXEC_* : Execution errors (runtime failures) - EXEC_INTERNAL_ERROR = "EXEC_INTERNAL_ERROR", -- Unexpected runtime error - EXEC_FILE_NOT_FOUND = "EXEC_FILE_NOT_FOUND", -- File does not exist - EXEC_FILE_READ_ERROR = "EXEC_FILE_READ_ERROR", -- Failed to read file - EXEC_FILE_WRITE_ERROR = "EXEC_FILE_WRITE_ERROR", -- Failed to write file - EXEC_INVALID_SAVE_FORMAT = "EXEC_INVALID_SAVE_FORMAT", -- Invalid save file format - - -- - -- Here are some examples of future error codes: - -- PROTO_INCOMPLETE - No newline terminator - -- STATE_TRANSITION_FAILED - State transition error - -- GAME_INSUFFICIENT_FUNDS - Not enough money - -- GAME_NO_SPACE - No space in inventory/shop - -- GAME_ITEM_NOT_FOUND - Item/card not found - -- GAME_MISSING_OBJECT - Required game object missing - -- GAME_INVALID_ACTION - Invalid game action - -- SEMANTIC_CARD_NOT_SELLABLE - Card cannot be sold - -- SEMANTIC_CONSUMABLE_REQUIRES_TARGET - Consumable needs target - -- SEMANTIC_CONSUMABLE_NOT_USABLE - Consumable cannot be used - -- SEMANTIC_CANNOT_SKIP_BOSS - Boss blind cannot be skipped - -- SEMANTIC_NO_DISCARDS_LEFT - No discards remaining - -- SEMANTIC_UNIQUE_ITEM_OWNED - Already own unique item - -- EXEC_TIMEOUT - Request timeout - -- EXEC_DISCONNECT - Client disconnected + BAD_REQUEST = "BAD_REQUEST", + INVALID_STATE = "INVALID_STATE", + NOT_ALLOWED = "NOT_ALLOWED", + INTERNAL_ERROR = "INTERNAL_ERROR", } From b1ec98dda092dd5351921ac920bf1456b1ff98a8 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Sun, 30 Nov 2025 12:18:29 +0100 Subject: [PATCH 167/230] refactor: update lua code to use simplified error codes --- src/lua/core/dispatcher.lua | 10 +++--- src/lua/core/server.lua | 8 ++--- src/lua/core/validator.lua | 14 ++++---- src/lua/endpoints/add.lua | 30 ++++++++-------- src/lua/endpoints/buy.lua | 16 ++++----- src/lua/endpoints/discard.lua | 8 ++--- src/lua/endpoints/load.lua | 8 ++--- src/lua/endpoints/play.lua | 6 ++-- src/lua/endpoints/rearrange.lua | 18 +++++----- src/lua/endpoints/reroll.lua | 2 +- src/lua/endpoints/save.lua | 6 ++-- src/lua/endpoints/sell.lua | 10 +++--- src/lua/endpoints/set.lua | 18 +++++----- src/lua/endpoints/skip.lua | 2 +- src/lua/endpoints/start.lua | 6 ++-- src/lua/endpoints/use.lua | 18 +++++----- tests/lua/core/test_dispatcher.py | 20 +++++------ tests/lua/core/test_server.py | 49 ++++---------------------- tests/lua/core/test_validator.py | 28 +++++++-------- tests/lua/endpoints/test_add.py | 44 +++++++++++------------ tests/lua/endpoints/test_buy.py | 26 +++++++------- tests/lua/endpoints/test_cash_out.py | 2 +- tests/lua/endpoints/test_discard.py | 14 ++++---- tests/lua/endpoints/test_load.py | 4 +-- tests/lua/endpoints/test_next_round.py | 2 +- tests/lua/endpoints/test_play.py | 12 +++---- tests/lua/endpoints/test_rearrange.py | 22 ++++++------ tests/lua/endpoints/test_reroll.py | 4 +-- tests/lua/endpoints/test_save.py | 6 ++-- tests/lua/endpoints/test_select.py | 2 +- tests/lua/endpoints/test_sell.py | 20 +++++------ tests/lua/endpoints/test_set.py | 32 ++++++++--------- tests/lua/endpoints/test_skip.py | 4 +-- tests/lua/endpoints/test_start.py | 14 ++++---- tests/lua/endpoints/test_use.py | 34 +++++++++--------- 35 files changed, 241 insertions(+), 278 deletions(-) diff --git a/src/lua/core/dispatcher.lua b/src/lua/core/dispatcher.lua index 4e9514f..71f4548 100644 --- a/src/lua/core/dispatcher.lua +++ b/src/lua/core/dispatcher.lua @@ -201,20 +201,20 @@ function BB_DISPATCHER.dispatch(request) -- Validate request has 'name' field if not request.name or type(request.name) ~= "string" then - BB_DISPATCHER.send_error("Request missing 'name' field", errors.PROTO_MISSING_NAME) + BB_DISPATCHER.send_error("Request missing 'name' field", errors.BAD_REQUEST) return end -- Validate request has 'arguments' field if not request.arguments then - BB_DISPATCHER.send_error("Request missing 'arguments' field", errors.PROTO_MISSING_ARGUMENTS) + BB_DISPATCHER.send_error("Request missing 'arguments' field", errors.BAD_REQUEST) return end -- Find endpoint local endpoint = BB_DISPATCHER.endpoints[request.name] if not endpoint then - BB_DISPATCHER.send_error("Unknown endpoint: " .. request.name, errors.PROTO_UNKNOWN_ENDPOINT) + BB_DISPATCHER.send_error("Unknown endpoint: " .. request.name, errors.BAD_REQUEST) return end @@ -255,7 +255,7 @@ function BB_DISPATCHER.dispatch(request) BB_DISPATCHER.send_error( "Endpoint '" .. request.name .. "' requires one of these states: " .. table.concat(state_names, ", "), - errors.STATE_INVALID_STATE + errors.INVALID_STATE ) return end @@ -283,6 +283,6 @@ function BB_DISPATCHER.dispatch(request) -- Endpoint threw an error local error_message = tostring(exec_error) - BB_DISPATCHER.send_error(error_message, errors.EXEC_INTERNAL_ERROR) + BB_DISPATCHER.send_error(error_message, errors.INTERNAL_ERROR) end end diff --git a/src/lua/core/server.lua b/src/lua/core/server.lua index e7e737f..84f3c7d 100644 --- a/src/lua/core/server.lua +++ b/src/lua/core/server.lua @@ -120,7 +120,7 @@ function BB_SERVER.receive() -- Check message size (line doesn't include the \n, so +1 for newline) if #line + 1 > 256 then - BB_SERVER.send_error("Request too large: maximum 256 bytes including newline", "PROTO_PAYLOAD") + BB_SERVER.send_error("Request too large: maximum 256 bytes including newline", "BAD_REQUEST") return {} end @@ -132,7 +132,7 @@ function BB_SERVER.receive() -- Check that JSON starts with '{' (must be object, not array/primitive) local trimmed = line:match("^%s*(.-)%s*$") if not trimmed:match("^{") then - BB_SERVER.send_error("Invalid JSON in request: must be object (start with '{')", "PROTO_INVALID_JSON") + BB_SERVER.send_error("Invalid JSON in request: must be object (start with '{')", "BAD_REQUEST") return {} end @@ -141,7 +141,7 @@ function BB_SERVER.receive() if success and type(parsed) == "table" then return { parsed } else - BB_SERVER.send_error("Invalid JSON in request", "PROTO_INVALID_JSON") + BB_SERVER.send_error("Invalid JSON in request", "BAD_REQUEST") return {} end end @@ -204,7 +204,7 @@ function BB_SERVER.update(dispatcher) dispatcher.dispatch(request, BB_SERVER.client_socket) else -- Placeholder: send error that dispatcher not ready - BB_SERVER.send_error("Server not fully initialized (dispatcher not ready)", "STATE_NOT_READY") + BB_SERVER.send_error("Server not fully initialized (dispatcher not ready)", "INVALID_STATE") end end end diff --git a/src/lua/core/validator.lua b/src/lua/core/validator.lua index 26a169e..3ee3b6c 100644 --- a/src/lua/core/validator.lua +++ b/src/lua/core/validator.lua @@ -71,21 +71,21 @@ local function validate_field(field_name, value, field_schema) -- Check type if expected_type == "integer" then if not is_integer(value) then - return false, "Field '" .. field_name .. "' must be an integer", errors.SCHEMA_INVALID_TYPE + return false, "Field '" .. field_name .. "' must be an integer", errors.BAD_REQUEST end elseif expected_type == "array" then if not is_array(value) then - return false, "Field '" .. field_name .. "' must be an array", errors.SCHEMA_INVALID_TYPE + return false, "Field '" .. field_name .. "' must be an array", errors.BAD_REQUEST end elseif expected_type == "table" then -- Empty tables are allowed, non-empty arrays are rejected if type(value) ~= "table" or (next(value) ~= nil and is_array(value)) then - return false, "Field '" .. field_name .. "' must be a table", errors.SCHEMA_INVALID_TYPE + return false, "Field '" .. field_name .. "' must be a table", errors.BAD_REQUEST end else -- Standard Lua types: string, boolean if type(value) ~= expected_type then - return false, "Field '" .. field_name .. "' must be of type " .. expected_type, errors.SCHEMA_INVALID_TYPE + return false, "Field '" .. field_name .. "' must be of type " .. expected_type, errors.BAD_REQUEST end end @@ -104,7 +104,7 @@ local function validate_field(field_name, value, field_schema) if not item_valid then return false, "Field '" .. field_name .. "' array item at index " .. (i - 1) .. " must be of type " .. item_type, - errors.SCHEMA_INVALID_ARRAY_ITEMS + errors.BAD_REQUEST end end end @@ -121,7 +121,7 @@ end function Validator.validate(args, schema) -- Ensure args is a table if type(args) ~= "table" then - return false, "Arguments must be a table", errors.SCHEMA_INVALID_TYPE + return false, "Arguments must be a table", errors.BAD_REQUEST end -- Validate each field in the schema @@ -130,7 +130,7 @@ function Validator.validate(args, schema) -- Check required fields if field_schema.required and value == nil then - return false, "Missing required field '" .. field_name .. "'", errors.SCHEMA_MISSING_REQUIRED + return false, "Missing required field '" .. field_name .. "'", errors.BAD_REQUEST end -- Validate field if present (skip optional fields that are nil) diff --git a/src/lua/endpoints/add.lua b/src/lua/endpoints/add.lua index 026b85f..a01e55c 100644 --- a/src/lua/endpoints/add.lua +++ b/src/lua/endpoints/add.lua @@ -159,7 +159,7 @@ return { if not card_type then send_response({ error = "Invalid card key format. Expected: joker (j_*), consumable (c_*), voucher (v_*), or playing card (SUIT_RANK)", - error_code = BB_ERRORS.SCHEMA_INVALID_VALUE, + error_code = BB_ERRORS.BAD_REQUEST, }) return end @@ -168,7 +168,7 @@ return { if card_type == "playing_card" and G.STATE ~= G.STATES.SELECTING_HAND then send_response({ error = "Playing cards can only be added in SELECTING_HAND state", - error_code = BB_ERRORS.STATE_INVALID_STATE, + error_code = BB_ERRORS.INVALID_STATE, }) return end @@ -177,7 +177,7 @@ return { if card_type == "voucher" and G.STATE ~= G.STATES.SHOP then send_response({ error = "Vouchers can only be added in SHOP state", - error_code = BB_ERRORS.STATE_INVALID_STATE, + error_code = BB_ERRORS.INVALID_STATE, }) return end @@ -186,7 +186,7 @@ return { if args.seal and card_type ~= "playing_card" then send_response({ error = "Seal can only be applied to playing cards", - error_code = BB_ERRORS.SCHEMA_INVALID_VALUE, + error_code = BB_ERRORS.BAD_REQUEST, }) return end @@ -198,7 +198,7 @@ return { if not seal_value then send_response({ error = "Invalid seal value. Expected: RED, BLUE, GOLD, or PURPLE", - error_code = BB_ERRORS.SCHEMA_INVALID_VALUE, + error_code = BB_ERRORS.BAD_REQUEST, }) return end @@ -208,7 +208,7 @@ return { if args.edition and card_type == "voucher" then send_response({ error = "Edition cannot be applied to vouchers", - error_code = BB_ERRORS.SCHEMA_INVALID_VALUE, + error_code = BB_ERRORS.BAD_REQUEST, }) return end @@ -217,7 +217,7 @@ return { if args.edition and card_type == "consumable" and args.edition ~= "NEGATIVE" then send_response({ error = "Consumables can only have NEGATIVE edition", - error_code = BB_ERRORS.SCHEMA_INVALID_VALUE, + error_code = BB_ERRORS.BAD_REQUEST, }) return end @@ -229,7 +229,7 @@ return { if not edition_value then send_response({ error = "Invalid edition value. Expected: HOLO, FOIL, POLYCHROME, or NEGATIVE", - error_code = BB_ERRORS.SCHEMA_INVALID_VALUE, + error_code = BB_ERRORS.BAD_REQUEST, }) return end @@ -239,7 +239,7 @@ return { if args.enhancement and card_type ~= "playing_card" then send_response({ error = "Enhancement can only be applied to playing cards", - error_code = BB_ERRORS.SCHEMA_INVALID_VALUE, + error_code = BB_ERRORS.BAD_REQUEST, }) return end @@ -251,7 +251,7 @@ return { if not enhancement_value then send_response({ error = "Invalid enhancement value. Expected: BONUS, MULT, WILD, GLASS, STEEL, STONE, GOLD, or LUCKY", - error_code = BB_ERRORS.SCHEMA_INVALID_VALUE, + error_code = BB_ERRORS.BAD_REQUEST, }) return end @@ -261,7 +261,7 @@ return { if args.eternal and card_type ~= "joker" then send_response({ error = "Eternal can only be applied to jokers", - error_code = BB_ERRORS.SCHEMA_INVALID_VALUE, + error_code = BB_ERRORS.BAD_REQUEST, }) return end @@ -270,7 +270,7 @@ return { if args.perishable and card_type ~= "joker" then send_response({ error = "Perishable can only be applied to jokers", - error_code = BB_ERRORS.SCHEMA_INVALID_VALUE, + error_code = BB_ERRORS.BAD_REQUEST, }) return end @@ -280,7 +280,7 @@ return { if type(args.perishable) ~= "number" or args.perishable ~= math.floor(args.perishable) or args.perishable < 1 then send_response({ error = "Perishable must be a positive integer (>= 1)", - error_code = BB_ERRORS.SCHEMA_INVALID_VALUE, + error_code = BB_ERRORS.BAD_REQUEST, }) return end @@ -290,7 +290,7 @@ return { if args.rental and card_type ~= "joker" then send_response({ error = "Rental can only be applied to jokers", - error_code = BB_ERRORS.SCHEMA_INVALID_VALUE, + error_code = BB_ERRORS.BAD_REQUEST, }) return end @@ -371,7 +371,7 @@ return { if not success then send_response({ error = "Failed to add card: " .. args.key, - error_code = BB_ERRORS.SCHEMA_INVALID_VALUE, + error_code = BB_ERRORS.BAD_REQUEST, }) return end diff --git a/src/lua/endpoints/buy.lua b/src/lua/endpoints/buy.lua index 0fd9d01..d968979 100644 --- a/src/lua/endpoints/buy.lua +++ b/src/lua/endpoints/buy.lua @@ -64,7 +64,7 @@ return { if not area then send_response({ error = "Invalid arguments. You must provide one of: card, voucher, pack", - error_code = BB_ERRORS.SCHEMA_INVALID_VALUE, + error_code = BB_ERRORS.BAD_REQUEST, }) return end @@ -73,7 +73,7 @@ return { if set > 1 then send_response({ error = "Invalid arguments. Cannot provide more than one of: card, voucher, or pack", - error_code = BB_ERRORS.SCHEMA_INVALID_VALUE, + error_code = BB_ERRORS.BAD_REQUEST, }) return end @@ -90,7 +90,7 @@ return { end send_response({ error = msg, - error_code = BB_ERRORS.SCHEMA_INVALID_VALUE, + error_code = BB_ERRORS.BAD_REQUEST, }) return end @@ -99,7 +99,7 @@ return { if not area.cards[pos] then send_response({ error = "Card index out of range. Index: " .. args.card .. ", Available cards: " .. area.count, - error_code = BB_ERRORS.SCHEMA_INVALID_VALUE, + error_code = BB_ERRORS.BAD_REQUEST, }) return end @@ -111,7 +111,7 @@ return { if card.cost.buy > G.GAME.dollars then send_response({ error = "Card is not affordable. Cost: " .. card.cost.buy .. ", Current money: " .. gamestate.money, - error_code = BB_ERRORS.SCHEMA_INVALID_VALUE, + error_code = BB_ERRORS.BAD_REQUEST, }) return end @@ -124,7 +124,7 @@ return { .. gamestate.jokers.count .. ", Limit: " .. gamestate.jokers.limit, - error_code = BB_ERRORS.SCHEMA_INVALID_VALUE, + error_code = BB_ERRORS.BAD_REQUEST, }) return end @@ -138,7 +138,7 @@ return { .. gamestate.consumables.count .. ", Limit: " .. gamestate.consumables.limit, - error_code = BB_ERRORS.SCHEMA_INVALID_VALUE, + error_code = BB_ERRORS.BAD_REQUEST, }) return end @@ -173,7 +173,7 @@ return { if not btn then send_response({ error = "No buy button found for card", - error_code = BB_ERRORS.GAME_INVALID_STATE, + error_code = BB_ERRORS.NOT_ALLOWED, }) return end diff --git a/src/lua/endpoints/discard.lua b/src/lua/endpoints/discard.lua index 4e94898..fcf7373 100644 --- a/src/lua/endpoints/discard.lua +++ b/src/lua/endpoints/discard.lua @@ -26,7 +26,7 @@ return { if #args.cards == 0 then send_response({ error = "Must provide at least one card to discard", - error_code = BB_ERRORS.SCHEMA_INVALID_VALUE, + error_code = BB_ERRORS.BAD_REQUEST, }) return end @@ -34,7 +34,7 @@ return { if G.GAME.current_round.discards_left <= 0 then send_response({ error = "No discards left", - error_code = BB_ERRORS.SCHEMA_INVALID_VALUE, + error_code = BB_ERRORS.BAD_REQUEST, }) return end @@ -42,7 +42,7 @@ return { if #args.cards > G.hand.config.highlighted_limit then send_response({ error = "You can only discard " .. G.hand.config.highlighted_limit .. " cards", - error_code = BB_ERRORS.SCHEMA_INVALID_VALUE, + error_code = BB_ERRORS.BAD_REQUEST, }) return end @@ -51,7 +51,7 @@ return { if not G.hand.cards[card_index + 1] then send_response({ error = "Invalid card index: " .. card_index, - error_code = BB_ERRORS.SCHEMA_INVALID_VALUE, + error_code = BB_ERRORS.BAD_REQUEST, }) return end diff --git a/src/lua/endpoints/load.lua b/src/lua/endpoints/load.lua index dfa9fcc..c395f70 100644 --- a/src/lua/endpoints/load.lua +++ b/src/lua/endpoints/load.lua @@ -34,7 +34,7 @@ return { if not file_info or file_info.type ~= "file" then send_response({ error = "File not found: '" .. path .. "'", - error_code = BB_ERRORS.EXEC_FILE_NOT_FOUND, + error_code = BB_ERRORS.INTERNAL_ERROR, }) return end @@ -45,7 +45,7 @@ return { if not compressed_data then send_response({ error = "Failed to read save file", - error_code = BB_ERRORS.EXEC_INTERNAL_ERROR, + error_code = BB_ERRORS.INTERNAL_ERROR, }) return end @@ -59,7 +59,7 @@ return { if not write_success then send_response({ error = "Failed to prepare save file for loading", - error_code = BB_ERRORS.EXEC_INTERNAL_ERROR, + error_code = BB_ERRORS.INTERNAL_ERROR, }) return end @@ -71,7 +71,7 @@ return { if G.SAVED_GAME == nil then send_response({ error = "Invalid save file format", - error_code = BB_ERRORS.EXEC_INVALID_SAVE_FORMAT, + error_code = BB_ERRORS.INTERNAL_ERROR, }) love.filesystem.remove(temp_filename) return diff --git a/src/lua/endpoints/play.lua b/src/lua/endpoints/play.lua index ef52a26..9a126ec 100644 --- a/src/lua/endpoints/play.lua +++ b/src/lua/endpoints/play.lua @@ -26,7 +26,7 @@ return { if #args.cards == 0 then send_response({ error = "Must provide at least one card to play", - error_code = BB_ERRORS.SCHEMA_INVALID_VALUE, + error_code = BB_ERRORS.BAD_REQUEST, }) return end @@ -34,7 +34,7 @@ return { if #args.cards > G.hand.config.highlighted_limit then send_response({ error = "You can only play " .. G.hand.config.highlighted_limit .. " cards", - error_code = BB_ERRORS.SCHEMA_INVALID_VALUE, + error_code = BB_ERRORS.BAD_REQUEST, }) return end @@ -43,7 +43,7 @@ return { if not G.hand.cards[card_index + 1] then send_response({ error = "Invalid card index: " .. card_index, - error_code = BB_ERRORS.SCHEMA_INVALID_VALUE, + error_code = BB_ERRORS.BAD_REQUEST, }) return end diff --git a/src/lua/endpoints/rearrange.lua b/src/lua/endpoints/rearrange.lua index 06967ef..2375e64 100644 --- a/src/lua/endpoints/rearrange.lua +++ b/src/lua/endpoints/rearrange.lua @@ -43,13 +43,13 @@ return { if param_count == 0 then send_response({ error = "Must provide exactly one of: hand, jokers, or consumables", - error_code = BB_ERRORS.SCHEMA_INVALID_VALUE, + error_code = BB_ERRORS.BAD_REQUEST, }) return elseif param_count > 1 then send_response({ error = "Can only rearrange one type at a time", - error_code = BB_ERRORS.SCHEMA_INVALID_VALUE, + error_code = BB_ERRORS.BAD_REQUEST, }) return end @@ -62,7 +62,7 @@ return { if G.STATE ~= G.STATES.SELECTING_HAND then send_response({ error = "Can only rearrange hand during hand selection", - error_code = BB_ERRORS.STATE_INVALID_STATE, + error_code = BB_ERRORS.INVALID_STATE, }) return end @@ -71,7 +71,7 @@ return { if not G.hand or not G.hand.cards then send_response({ error = "No hand available to rearrange", - error_code = BB_ERRORS.GAME_INVALID_STATE, + error_code = BB_ERRORS.NOT_ALLOWED, }) return end @@ -85,7 +85,7 @@ return { if not G.jokers or not G.jokers.cards then send_response({ error = "No jokers available to rearrange", - error_code = BB_ERRORS.GAME_INVALID_STATE, + error_code = BB_ERRORS.NOT_ALLOWED, }) return end @@ -99,7 +99,7 @@ return { if not G.consumeables or not G.consumeables.cards then send_response({ error = "No consumables available to rearrange", - error_code = BB_ERRORS.GAME_INVALID_STATE, + error_code = BB_ERRORS.NOT_ALLOWED, }) return end @@ -117,7 +117,7 @@ return { if #indices ~= #source_array then send_response({ error = "Must provide exactly " .. #source_array .. " indices for " .. type_name, - error_code = BB_ERRORS.SCHEMA_INVALID_VALUE, + error_code = BB_ERRORS.BAD_REQUEST, }) return end @@ -129,7 +129,7 @@ return { if idx < 0 or idx >= #source_array then send_response({ error = "Index out of range for " .. type_name .. ": " .. idx, - error_code = BB_ERRORS.SCHEMA_INVALID_VALUE, + error_code = BB_ERRORS.BAD_REQUEST, }) return end @@ -138,7 +138,7 @@ return { if seen[idx] then send_response({ error = "Duplicate index in " .. type_name .. ": " .. idx, - error_code = BB_ERRORS.SCHEMA_INVALID_VALUE, + error_code = BB_ERRORS.BAD_REQUEST, }) return end diff --git a/src/lua/endpoints/reroll.lua b/src/lua/endpoints/reroll.lua index cf6cdd2..09df74b 100644 --- a/src/lua/endpoints/reroll.lua +++ b/src/lua/endpoints/reroll.lua @@ -22,7 +22,7 @@ return { if G.GAME.dollars < reroll_cost then send_response({ error = "Not enough dollars to reroll. Current: " .. G.GAME.dollars .. ", Required: " .. reroll_cost, - error_code = BB_ERRORS.GAME_INVALID_STATE, + error_code = BB_ERRORS.NOT_ALLOWED, }) return end diff --git a/src/lua/endpoints/save.lua b/src/lua/endpoints/save.lua index 82fff21..0531a66 100644 --- a/src/lua/endpoints/save.lua +++ b/src/lua/endpoints/save.lua @@ -50,7 +50,7 @@ return { if not G.STAGE or G.STAGE ~= G.STAGES.RUN then send_response({ error = "Can only save during an active run", - error_code = BB_ERRORS.GAME_NOT_IN_RUN, + error_code = BB_ERRORS.INVALID_STATE, }) return end @@ -70,7 +70,7 @@ return { if not compressed_data then send_response({ error = "Failed to save game state", - error_code = BB_ERRORS.EXEC_INTERNAL_ERROR, + error_code = BB_ERRORS.INTERNAL_ERROR, }) return end @@ -79,7 +79,7 @@ return { if not write_success then send_response({ error = "Failed to write save file to '" .. path .. "'", - error_code = BB_ERRORS.EXEC_INTERNAL_ERROR, + error_code = BB_ERRORS.INTERNAL_ERROR, }) return end diff --git a/src/lua/endpoints/sell.lua b/src/lua/endpoints/sell.lua index 3e515b2..ca6e95c 100644 --- a/src/lua/endpoints/sell.lua +++ b/src/lua/endpoints/sell.lua @@ -37,13 +37,13 @@ return { if param_count == 0 then send_response({ error = "Must provide exactly one of: joker or consumable", - error_code = BB_ERRORS.SCHEMA_INVALID_VALUE, + error_code = BB_ERRORS.BAD_REQUEST, }) return elseif param_count > 1 then send_response({ error = "Can only sell one item at a time", - error_code = BB_ERRORS.SCHEMA_INVALID_VALUE, + error_code = BB_ERRORS.BAD_REQUEST, }) return end @@ -56,7 +56,7 @@ return { if not G.jokers or not G.jokers.config or G.jokers.config.card_count == 0 then send_response({ error = "No jokers available to sell", - error_code = BB_ERRORS.GAME_INVALID_STATE, + error_code = BB_ERRORS.NOT_ALLOWED, }) return end @@ -68,7 +68,7 @@ return { if not G.consumeables or not G.consumeables.config or G.consumeables.config.card_count == 0 then send_response({ error = "No consumables available to sell", - error_code = BB_ERRORS.GAME_INVALID_STATE, + error_code = BB_ERRORS.NOT_ALLOWED, }) return end @@ -81,7 +81,7 @@ return { if not source_array[pos] then send_response({ error = "Index out of range for " .. sell_type .. ": " .. (pos - 1), - error_code = BB_ERRORS.SCHEMA_INVALID_VALUE, + error_code = BB_ERRORS.BAD_REQUEST, }) return end diff --git a/src/lua/endpoints/set.lua b/src/lua/endpoints/set.lua index 39c499b..ad41a5f 100644 --- a/src/lua/endpoints/set.lua +++ b/src/lua/endpoints/set.lua @@ -61,7 +61,7 @@ return { if G.STAGE and G.STAGE ~= G.STAGES.RUN then send_response({ error = "Can only set during an active run", - error_code = BB_ERRORS.GAME_NOT_IN_RUN, + error_code = BB_ERRORS.INVALID_STATE, }) return end @@ -78,7 +78,7 @@ return { then send_response({ error = "Must provide at least one field to set", - error_code = BB_ERRORS.SCHEMA_INVALID_VALUE, + error_code = BB_ERRORS.BAD_REQUEST, }) return end @@ -88,7 +88,7 @@ return { if args.money < 0 then send_response({ error = "Money must be a positive integer", - error_code = BB_ERRORS.SCHEMA_INVALID_VALUE, + error_code = BB_ERRORS.BAD_REQUEST, }) return end @@ -101,7 +101,7 @@ return { if args.chips < 0 then send_response({ error = "Chips must be a positive integer", - error_code = BB_ERRORS.SCHEMA_INVALID_VALUE, + error_code = BB_ERRORS.BAD_REQUEST, }) return end @@ -114,7 +114,7 @@ return { if args.ante < 0 then send_response({ error = "Ante must be a positive integer", - error_code = BB_ERRORS.SCHEMA_INVALID_VALUE, + error_code = BB_ERRORS.BAD_REQUEST, }) return end @@ -127,7 +127,7 @@ return { if args.round < 0 then send_response({ error = "Round must be a positive integer", - error_code = BB_ERRORS.SCHEMA_INVALID_VALUE, + error_code = BB_ERRORS.BAD_REQUEST, }) return end @@ -140,7 +140,7 @@ return { if args.hands < 0 then send_response({ error = "Hands must be a positive integer", - error_code = BB_ERRORS.SCHEMA_INVALID_VALUE, + error_code = BB_ERRORS.BAD_REQUEST, }) return end @@ -153,7 +153,7 @@ return { if args.discards < 0 then send_response({ error = "Discards must be a positive integer", - error_code = BB_ERRORS.SCHEMA_INVALID_VALUE, + error_code = BB_ERRORS.BAD_REQUEST, }) return end @@ -165,7 +165,7 @@ return { if G.STATE ~= G.STATES.SHOP then send_response({ error = "Can re-stock shop only in SHOP state", - error_code = BB_ERRORS.GAME_INVALID_STATE, + error_code = BB_ERRORS.NOT_ALLOWED, }) return end diff --git a/src/lua/endpoints/skip.lua b/src/lua/endpoints/skip.lua index aa2aaec..1602fa6 100644 --- a/src/lua/endpoints/skip.lua +++ b/src/lua/endpoints/skip.lua @@ -26,7 +26,7 @@ return { sendDebugMessage("skip() cannot skip Boss blind: " .. current_blind, "BB.ENDPOINTS") send_response({ error = "Cannot skip Boss blind", - error_code = BB_ERRORS.GAME_INVALID_STATE, + error_code = BB_ERRORS.NOT_ALLOWED, }) return end diff --git a/src/lua/endpoints/start.lua b/src/lua/endpoints/start.lua index 41b884e..cb92163 100644 --- a/src/lua/endpoints/start.lua +++ b/src/lua/endpoints/start.lua @@ -76,7 +76,7 @@ return { send_response({ error = "Invalid stake enum. Must be one of: WHITE, RED, GREEN, BLACK, BLUE, PURPLE, ORANGE, GOLD. Got: " .. tostring(args.stake), - error_code = BB_ERRORS.SCHEMA_INVALID_VALUE, + error_code = BB_ERRORS.BAD_REQUEST, }) return end @@ -88,7 +88,7 @@ return { send_response({ error = "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), - error_code = BB_ERRORS.SCHEMA_INVALID_VALUE, + error_code = BB_ERRORS.BAD_REQUEST, }) return end @@ -115,7 +115,7 @@ return { sendDebugMessage("start() deck not found in game data: " .. deck_name, "BB.ENDPOINTS") send_response({ error = "Deck not found in game data: " .. deck_name, - error_code = BB_ERRORS.EXEC_INTERNAL_ERROR, + error_code = BB_ERRORS.INTERNAL_ERROR, }) return end diff --git a/src/lua/endpoints/use.lua b/src/lua/endpoints/use.lua index 114629c..5de0463 100644 --- a/src/lua/endpoints/use.lua +++ b/src/lua/endpoints/use.lua @@ -32,7 +32,7 @@ return { if args.consumable < 0 or args.consumable >= #G.consumeables.cards then send_response({ error = "Consumable index out of range: " .. args.consumable, - error_code = BB_ERRORS.SCHEMA_INVALID_VALUE, + error_code = BB_ERRORS.BAD_REQUEST, }) return end @@ -48,7 +48,7 @@ return { error = "Consumable '" .. consumable_card.ability.name .. "' requires card selection and can only be used in SELECTING_HAND state", - error_code = BB_ERRORS.STATE_INVALID_STATE, + error_code = BB_ERRORS.INVALID_STATE, }) return end @@ -58,7 +58,7 @@ return { if not args.cards or #args.cards == 0 then send_response({ error = "Consumable '" .. consumable_card.ability.name .. "' requires card selection", - error_code = BB_ERRORS.SCHEMA_MISSING_REQUIRED, + error_code = BB_ERRORS.BAD_REQUEST, }) return end @@ -68,7 +68,7 @@ return { if card_idx < 0 or card_idx >= #G.hand.cards then send_response({ error = "Card index out of range: " .. card_idx, - error_code = BB_ERRORS.SCHEMA_INVALID_VALUE, + error_code = BB_ERRORS.BAD_REQUEST, }) return end @@ -91,7 +91,7 @@ return { min_cards == 1 and "" or "s", card_count ), - error_code = BB_ERRORS.SCHEMA_INVALID_VALUE, + error_code = BB_ERRORS.BAD_REQUEST, }) return end @@ -106,7 +106,7 @@ return { min_cards == 1 and "" or "s", card_count ), - error_code = BB_ERRORS.SCHEMA_INVALID_VALUE, + error_code = BB_ERRORS.BAD_REQUEST, }) return end @@ -120,7 +120,7 @@ return { max_cards == 1 and "" or "s", card_count ), - error_code = BB_ERRORS.SCHEMA_INVALID_VALUE, + error_code = BB_ERRORS.BAD_REQUEST, }) return end @@ -149,7 +149,7 @@ return { if not consumable_card:can_use_consumeable() then send_response({ error = "Consumable '" .. consumable_card.ability.name .. "' cannot be used at this time", - error_code = BB_ERRORS.GAME_INVALID_STATE, + error_code = BB_ERRORS.NOT_ALLOWED, }) return end @@ -158,7 +158,7 @@ return { if consumable_card:check_use() then send_response({ error = "Cannot use consumable '" .. consumable_card.ability.name .. "': insufficient space", - error_code = BB_ERRORS.GAME_INVALID_STATE, + error_code = BB_ERRORS.NOT_ALLOWED, }) return end diff --git a/tests/lua/core/test_dispatcher.py b/tests/lua/core/test_dispatcher.py index 279465a..957c565 100644 --- a/tests/lua/core/test_dispatcher.py +++ b/tests/lua/core/test_dispatcher.py @@ -34,7 +34,7 @@ def test_missing_name_field(self, client: socket.socket) -> None: assert "error" in data assert "error_code" in data - assert data["error_code"] == "PROTO_MISSING_NAME" + assert data["error_code"] == "BAD_REQUEST" assert "name" in data["error"].lower() def test_invalid_name_type(self, client: socket.socket) -> None: @@ -46,7 +46,7 @@ def test_invalid_name_type(self, client: socket.socket) -> None: data = json.loads(response) assert "error" in data - assert data["error_code"] == "PROTO_MISSING_NAME" + assert data["error_code"] == "BAD_REQUEST" def test_missing_arguments_field(self, client: socket.socket) -> None: """Test that requests without 'arguments' field are rejected.""" @@ -57,7 +57,7 @@ def test_missing_arguments_field(self, client: socket.socket) -> None: data = json.loads(response) assert "error" in data - assert data["error_code"] == "PROTO_MISSING_ARGUMENTS" + assert data["error_code"] == "BAD_REQUEST" assert "arguments" in data["error"].lower() def test_unknown_endpoint(self, client: socket.socket) -> None: @@ -69,7 +69,7 @@ def test_unknown_endpoint(self, client: socket.socket) -> None: data = json.loads(response) assert "error" in data - assert data["error_code"] == "PROTO_UNKNOWN_ENDPOINT" + assert data["error_code"] == "BAD_REQUEST" assert "nonexistent_endpoint" in data["error"] def test_valid_health_endpoint_request(self, client: socket.socket) -> None: @@ -114,7 +114,7 @@ def test_missing_required_field(self, client: socket.socket) -> None: data = json.loads(response) assert "error" in data - assert data["error_code"] == "SCHEMA_MISSING_REQUIRED" + assert data["error_code"] == "BAD_REQUEST" assert "required_string" in data["error"].lower() def test_invalid_type_string_instead_of_integer( @@ -140,7 +140,7 @@ def test_invalid_type_string_instead_of_integer( data = json.loads(response) assert "error" in data - assert data["error_code"] == "SCHEMA_INVALID_TYPE" + assert data["error_code"] == "BAD_REQUEST" assert "required_integer" in data["error"].lower() def test_array_item_type_validation(self, client: socket.socket) -> None: @@ -169,7 +169,7 @@ def test_array_item_type_validation(self, client: socket.socket) -> None: data = json.loads(response) assert "error" in data - assert data["error_code"] == "SCHEMA_INVALID_ARRAY_ITEMS" + assert data["error_code"] == "BAD_REQUEST" def test_valid_request_with_all_fields(self, client: socket.socket) -> None: """Test that valid requests with multiple fields pass validation.""" @@ -243,7 +243,7 @@ def test_state_validation_enforcement(self, client: socket.socket) -> None: # Response depends on current game state # Either succeeds if in correct state, or fails with STATE_INVALID_STATE if "error" in data: - assert data["error_code"] == "STATE_INVALID_STATE" + assert data["error_code"] == "INVALID_STATE" assert "requires" in data["error"].lower() else: assert "success" in data @@ -301,7 +301,7 @@ def test_execution_error_handling(self, client: socket.socket) -> None: data = json.loads(response) assert "error" in data - assert data["error_code"] == "EXEC_INTERNAL_ERROR" + assert data["error_code"] == "INTERNAL_ERROR" assert "Intentional test error" in data["error"] def test_execution_error_no_categorization(self, client: socket.socket) -> None: @@ -323,7 +323,7 @@ def test_execution_error_no_categorization(self, client: socket.socket) -> None: data = json.loads(response) # Should always be EXEC_INTERNAL_ERROR (no categorization) - assert data["error_code"] == "EXEC_INTERNAL_ERROR" + assert data["error_code"] == "INTERNAL_ERROR" def test_execution_success_when_no_error(self, client: socket.socket) -> None: """Test that endpoints can execute successfully.""" diff --git a/tests/lua/core/test_server.py b/tests/lua/core/test_server.py index ad11c64..d6c150d 100644 --- a/tests/lua/core/test_server.py +++ b/tests/lua/core/test_server.py @@ -180,7 +180,7 @@ def test_message_too_large(self, client: socket.socket) -> None: assert "error" in data assert "error_code" in data - assert data["error_code"] == "PROTO_PAYLOAD" + assert data["error_code"] == "BAD_REQUEST" assert "too large" in data["error"].lower() def test_pipelined_messages_rejected(self, client: socket.socket) -> None: @@ -218,7 +218,7 @@ def test_invalid_json_syntax(self, client: socket.socket) -> None: assert "error" in data assert "error_code" in data - assert data["error_code"] == "PROTO_INVALID_JSON" + assert data["error_code"] == "BAD_REQUEST" def test_json_string_rejected(self, client: socket.socket) -> None: """Test that JSON strings are rejected (must be object).""" @@ -229,7 +229,7 @@ def test_json_string_rejected(self, client: socket.socket) -> None: assert "error" in data assert "error_code" in data - assert data["error_code"] == "PROTO_INVALID_JSON" + assert data["error_code"] == "BAD_REQUEST" def test_json_number_rejected(self, client: socket.socket) -> None: """Test that JSON numbers are rejected (must be object).""" @@ -240,7 +240,7 @@ def test_json_number_rejected(self, client: socket.socket) -> None: assert "error" in data assert "error_code" in data - assert data["error_code"] == "PROTO_INVALID_JSON" + assert data["error_code"] == "BAD_REQUEST" def test_json_array_rejected(self, client: socket.socket) -> None: """Test that JSON arrays are rejected (must be object starting with '{').""" @@ -251,7 +251,7 @@ def test_json_array_rejected(self, client: socket.socket) -> None: assert "error" in data assert "error_code" in data - assert data["error_code"] == "PROTO_INVALID_JSON" + assert data["error_code"] == "BAD_REQUEST" def test_only_whitespace_line_rejected(self, client: socket.socket) -> None: """Test that whitespace-only lines are rejected as invalid JSON.""" @@ -263,44 +263,7 @@ def test_only_whitespace_line_rejected(self, client: socket.socket) -> None: # Should be rejected as invalid JSON (trimmed to empty, doesn't start with '{') assert "error" in data - assert data["error_code"] == "PROTO_INVALID_JSON" - - def test_valid_json_with_nested_objects(self, client: socket.socket) -> None: - """Test complex valid JSON with nested structures is accepted.""" - complex_msg = { - "name": "test", - "arguments": { - "nested": {"level1": {"level2": {"level3": "value"}}}, - "array": [1, 2, 3], - "mixed": {"a": [{"b": "c"}]}, - }, - } - msg = json.dumps(complex_msg) + "\n" - - # Ensure it's under size limit - if len(msg) <= 256: - client.send(msg.encode()) - - response = client.recv(BUFFER_SIZE).decode().strip() - data = json.loads(response) - - # Should be parsed successfully (not a protocol error) - if "error" in data: - assert data["error_code"] != "PROTO_INVALID_JSON" - - def test_json_with_escaped_characters(self, client: socket.socket) -> None: - """Test JSON with escaped quotes, newlines in strings, etc.""" - msg = json.dumps({"name": "test", "data": 'quotes: "hello"\nnewline'}) + "\n" - - if len(msg) <= 256: - client.send(msg.encode()) - - response = client.recv(BUFFER_SIZE).decode().strip() - data = json.loads(response) - - # Should be parsed successfully - if "error" in data: - assert data["error_code"] != "PROTO_INVALID_JSON" + assert data["error_code"] == "BAD_REQUEST" class TestBBServerSendResponse: diff --git a/tests/lua/core/test_validator.py b/tests/lua/core/test_validator.py index 2126234..8bc93c6 100644 --- a/tests/lua/core/test_validator.py +++ b/tests/lua/core/test_validator.py @@ -47,7 +47,7 @@ def test_invalid_string_type(self, client: socket.socket) -> None: ) assert_error_response( response, - expected_error_code="SCHEMA_INVALID_TYPE", + expected_error_code="BAD_REQUEST", expected_message_contains="string_field", ) @@ -75,7 +75,7 @@ def test_invalid_integer_type_float(self, client: socket.socket) -> None: ) assert_error_response( response, - expected_error_code="SCHEMA_INVALID_TYPE", + expected_error_code="BAD_REQUEST", expected_message_contains="integer_field", ) @@ -91,7 +91,7 @@ def test_invalid_integer_type_string(self, client: socket.socket) -> None: ) assert_error_response( response, - expected_error_code="SCHEMA_INVALID_TYPE", + expected_error_code="BAD_REQUEST", expected_message_contains="integer_field", ) @@ -119,7 +119,7 @@ def test_invalid_array_type_not_sequential(self, client: socket.socket) -> None: ) assert_error_response( response, - expected_error_code="SCHEMA_INVALID_TYPE", + expected_error_code="BAD_REQUEST", expected_message_contains="array_field", ) @@ -135,7 +135,7 @@ def test_invalid_array_type_string(self, client: socket.socket) -> None: ) assert_error_response( response, - expected_error_code="SCHEMA_INVALID_TYPE", + expected_error_code="BAD_REQUEST", expected_message_contains="array_field", ) @@ -175,7 +175,7 @@ def test_invalid_boolean_type_string(self, client: socket.socket) -> None: ) assert_error_response( response, - expected_error_code="SCHEMA_INVALID_TYPE", + expected_error_code="BAD_REQUEST", expected_message_contains="boolean_field", ) @@ -191,7 +191,7 @@ def test_invalid_boolean_type_number(self, client: socket.socket) -> None: ) assert_error_response( response, - expected_error_code="SCHEMA_INVALID_TYPE", + expected_error_code="BAD_REQUEST", expected_message_contains="boolean_field", ) @@ -231,7 +231,7 @@ def test_invalid_table_type_array(self, client: socket.socket) -> None: ) assert_error_response( response, - expected_error_code="SCHEMA_INVALID_TYPE", + expected_error_code="BAD_REQUEST", expected_message_contains="table_field", ) @@ -247,7 +247,7 @@ def test_invalid_table_type_string(self, client: socket.socket) -> None: ) assert_error_response( response, - expected_error_code="SCHEMA_INVALID_TYPE", + expected_error_code="BAD_REQUEST", expected_message_contains="table_field", ) @@ -278,7 +278,7 @@ def test_required_field_missing(self, client: socket.socket) -> None: ) assert_error_response( response, - expected_error_code="SCHEMA_MISSING_REQUIRED", + expected_error_code="BAD_REQUEST", expected_message_contains="required_field", ) @@ -327,7 +327,7 @@ def test_array_of_integers_invalid_float(self, client: socket.socket) -> None: ) assert_error_response( response, - expected_error_code="SCHEMA_INVALID_ARRAY_ITEMS", + expected_error_code="BAD_REQUEST", expected_message_contains="array_of_integers", ) @@ -343,7 +343,7 @@ def test_array_of_integers_invalid_string(self, client: socket.socket) -> None: ) assert_error_response( response, - expected_error_code="SCHEMA_INVALID_ARRAY_ITEMS", + expected_error_code="BAD_REQUEST", expected_message_contains="array_of_integers", ) @@ -372,8 +372,8 @@ def test_multiple_errors_returns_first(self, client: socket.socket) -> None: assert_error_response(response) # Verify it's one of the expected error codes assert response["error_code"] in [ - "SCHEMA_MISSING_REQUIRED", - "SCHEMA_INVALID_TYPE", + "BAD_REQUEST", + "BAD_REQUEST", ] diff --git a/tests/lua/endpoints/test_add.py b/tests/lua/endpoints/test_add.py index 7b846af..8a914a3 100644 --- a/tests/lua/endpoints/test_add.py +++ b/tests/lua/endpoints/test_add.py @@ -98,7 +98,7 @@ def test_add_no_key_provided(self, client: socket.socket) -> None: assert gamestate["state"] == "SELECTING_HAND" assert_error_response( api(client, "add", {}), - "SCHEMA_MISSING_REQUIRED", + "BAD_REQUEST", "Missing required field 'key'", ) @@ -116,7 +116,7 @@ def test_invalid_key_type_number(self, client: socket.socket) -> None: assert gamestate["state"] == "SELECTING_HAND" assert_error_response( api(client, "add", {"key": 123}), - "SCHEMA_INVALID_TYPE", + "BAD_REQUEST", "Field 'key' must be of type string", ) @@ -130,7 +130,7 @@ def test_invalid_key_unknown_format(self, client: socket.socket) -> None: assert gamestate["state"] == "SELECTING_HAND" assert_error_response( api(client, "add", {"key": "x_unknown"}), - "SCHEMA_INVALID_VALUE", + "BAD_REQUEST", "Invalid card key format. Expected: joker (j_*), consumable (c_*), voucher (v_*), or playing card (SUIT_RANK)", ) @@ -144,7 +144,7 @@ def test_invalid_key_known_format(self, client: socket.socket) -> None: assert gamestate["state"] == "SELECTING_HAND" assert_error_response( api(client, "add", {"key": "j_NON_EXTING_JOKER"}), - "SCHEMA_INVALID_VALUE", + "BAD_REQUEST", "Failed to add card: j_NON_EXTING_JOKER", ) @@ -158,7 +158,7 @@ def test_add_from_BLIND_SELECT(self, client: socket.socket) -> None: assert gamestate["state"] == "BLIND_SELECT" assert_error_response( api(client, "add", {"key": "j_joker"}), - "STATE_INVALID_STATE", + "INVALID_STATE", "Endpoint 'add' requires one of these states: SELECTING_HAND, SHOP, ROUND_EVAL", ) @@ -172,7 +172,7 @@ def test_add_playing_card_from_SHOP(self, client: socket.socket) -> None: assert gamestate["state"] == "SHOP" assert_error_response( api(client, "add", {"key": "H_A"}), - "STATE_INVALID_STATE", + "INVALID_STATE", "Playing cards can only be added in SELECTING_HAND state", ) @@ -186,7 +186,7 @@ def test_add_voucher_card_from_SELECTING_HAND(self, client: socket.socket) -> No assert gamestate["state"] == "SELECTING_HAND" assert_error_response( api(client, "add", {"key": "v_overstock"}), - "STATE_INVALID_STATE", + "INVALID_STATE", "Vouchers can only be added in SHOP state", ) @@ -221,7 +221,7 @@ def test_add_playing_card_invalid_seal(self, client: socket.socket) -> None: response = api(client, "add", {"key": "H_A", "seal": "WHITE"}) assert_error_response( response, - "SCHEMA_INVALID_VALUE", + "BAD_REQUEST", "Invalid seal value. Expected: RED, BLUE, GOLD, or PURPLE", ) @@ -239,7 +239,7 @@ def test_add_non_playing_card_with_seal_fails( response = api(client, "add", {"key": key, "seal": "RED"}) assert_error_response( response, - "SCHEMA_INVALID_VALUE", + "BAD_REQUEST", "Seal can only be applied to playing cards", ) @@ -308,7 +308,7 @@ def test_add_consumable_with_non_negative_edition_fails( response = api(client, "add", {"key": "c_fool", "edition": edition}) assert_error_response( response, - "SCHEMA_INVALID_VALUE", + "BAD_REQUEST", "Consumables can only have NEGATIVE edition", ) @@ -323,7 +323,7 @@ def test_add_voucher_with_edition_fails(self, client: socket.socket) -> None: assert gamestate["vouchers"]["count"] == 0 response = api(client, "add", {"key": "v_overstock_norm", "edition": "FOIL"}) assert_error_response( - response, "SCHEMA_INVALID_VALUE", "Edition cannot be applied to vouchers" + response, "BAD_REQUEST", "Edition cannot be applied to vouchers" ) def test_add_playing_card_invalid_edition(self, client: socket.socket) -> None: @@ -338,7 +338,7 @@ def test_add_playing_card_invalid_edition(self, client: socket.socket) -> None: response = api(client, "add", {"key": "H_A", "edition": "WHITE"}) assert_error_response( response, - "SCHEMA_INVALID_VALUE", + "BAD_REQUEST", "Invalid edition value. Expected: HOLO, FOIL, POLYCHROME, or NEGATIVE", ) @@ -378,7 +378,7 @@ def test_add_playing_card_invalid_enhancement(self, client: socket.socket) -> No response = api(client, "add", {"key": "H_A", "enhancement": "WHITE"}) assert_error_response( response, - "SCHEMA_INVALID_VALUE", + "BAD_REQUEST", "Invalid enhancement value. Expected: BONUS, MULT, WILD, GLASS, STEEL, STONE, GOLD, or LUCKY", ) @@ -397,7 +397,7 @@ def test_add_non_playing_card_with_enhancement_fails( response = api(client, "add", {"key": key, "enhancement": "BONUS"}) assert_error_response( response, - "SCHEMA_INVALID_VALUE", + "BAD_REQUEST", "Enhancement can only be applied to playing cards", ) @@ -433,7 +433,7 @@ def test_add_non_joker_with_eternal_fails( assert gamestate["consumables"]["count"] == 0 assert_error_response( api(client, "add", {"key": key, "eternal": True}), - "SCHEMA_INVALID_VALUE", + "BAD_REQUEST", "Eternal can only be applied to jokers", ) @@ -448,7 +448,7 @@ def test_add_playing_card_with_eternal_fails(self, client: socket.socket) -> Non assert gamestate["hand"]["count"] == 8 assert_error_response( api(client, "add", {"key": "H_A", "eternal": True}), - "SCHEMA_INVALID_VALUE", + "BAD_REQUEST", "Eternal can only be applied to jokers", ) @@ -501,7 +501,7 @@ def test_add_joker_with_perishable_invalid_value_fails( response = api(client, "add", {"key": "j_joker", "perishable": invalid_value}) assert_error_response( response, - "SCHEMA_INVALID_VALUE", + "BAD_REQUEST", "Perishable must be a positive integer (>= 1)", ) @@ -519,7 +519,7 @@ def test_add_joker_with_perishable_string_fails( response = api(client, "add", {"key": "j_joker", "perishable": "NOT_INT_1"}) assert_error_response( response, - "SCHEMA_INVALID_TYPE", + "BAD_REQUEST", "Field 'perishable' must be of type number", ) @@ -537,7 +537,7 @@ def test_add_non_joker_with_perishable_fails( response = api(client, "add", {"key": key, "perishable": 5}) assert_error_response( response, - "SCHEMA_INVALID_VALUE", + "BAD_REQUEST", "Perishable can only be applied to jokers", ) @@ -555,7 +555,7 @@ def test_add_playing_card_with_perishable_fails( response = api(client, "add", {"key": "H_A", "perishable": 5}) assert_error_response( response, - "SCHEMA_INVALID_VALUE", + "BAD_REQUEST", "Perishable can only be applied to jokers", ) @@ -587,7 +587,7 @@ def test_add_non_joker_with_rental_fails( assert gamestate["jokers"]["count"] == 0 assert_error_response( api(client, "add", {"key": key, "rental": True}), - "SCHEMA_INVALID_VALUE", + "BAD_REQUEST", "Rental can only be applied to jokers", ) @@ -619,6 +619,6 @@ def test_add_playing_card_with_rental_fails(self, client: socket.socket) -> None assert gamestate["hand"]["count"] == 8 assert_error_response( api(client, "add", {"key": "H_A", "rental": True}), - "SCHEMA_INVALID_VALUE", + "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 index b2df6d6..206a465 100644 --- a/tests/lua/endpoints/test_buy.py +++ b/tests/lua/endpoints/test_buy.py @@ -18,7 +18,7 @@ def test_buy_no_args(self, client: socket.socket) -> None: assert gamestate["shop"]["cards"][0]["set"] == "JOKER" assert_error_response( api(client, "buy", {}), - "SCHEMA_INVALID_VALUE", + "BAD_REQUEST", "Invalid arguments. You must provide one of: card, voucher, pack", ) @@ -30,7 +30,7 @@ def test_buy_multi_args(self, client: socket.socket) -> None: assert gamestate["shop"]["cards"][0]["set"] == "JOKER" assert_error_response( api(client, "buy", {"card": 0, "voucher": 0}), - "SCHEMA_INVALID_VALUE", + "BAD_REQUEST", "Invalid arguments. Cannot provide more than one of: card, voucher, or pack", ) @@ -41,7 +41,7 @@ def test_buy_no_card_in_shop_area(self, client: socket.socket) -> None: assert gamestate["shop"]["count"] == 0 assert_error_response( api(client, "buy", {"card": 0}), - "SCHEMA_INVALID_VALUE", + "BAD_REQUEST", "No jokers/consumables/cards in the shop. Reroll to restock the shop", ) @@ -52,7 +52,7 @@ def test_buy_invalid_index(self, client: socket.socket) -> None: assert gamestate["shop"]["cards"][0]["set"] == "JOKER" assert_error_response( api(client, "buy", {"card": 999}), - "SCHEMA_INVALID_VALUE", + "BAD_REQUEST", "Card index out of range. Index: 999, Available cards: 2", ) @@ -63,7 +63,7 @@ def test_buy_insufficient_funds(self, client: socket.socket) -> None: assert gamestate["money"] == 0 assert_error_response( api(client, "buy", {"card": 0}), - "SCHEMA_INVALID_VALUE", + "BAD_REQUEST", "Card is not affordable. Cost: 5, Current money: 0", ) @@ -77,7 +77,7 @@ def test_buy_joker_slots_full(self, client: socket.socket) -> None: assert gamestate["shop"]["cards"][0]["set"] == "JOKER" assert_error_response( api(client, "buy", {"card": 0}), - "SCHEMA_INVALID_VALUE", + "BAD_REQUEST", "Cannot purchase joker card, joker slots are full. Current: 5, Limit: 5", ) @@ -93,7 +93,7 @@ def test_buy_consumable_slots_full(self, client: socket.socket) -> None: assert gamestate["shop"]["cards"][1]["set"] == "PLANET" assert_error_response( api(client, "buy", {"card": 1}), - "SCHEMA_INVALID_VALUE", + "BAD_REQUEST", "Cannot purchase consumable card, consumable slots are full. Current: 2, Limit: 2", ) @@ -104,7 +104,7 @@ def test_buy_vouchers_slot_empty(self, client: socket.socket) -> None: assert gamestate["vouchers"]["count"] == 0 assert_error_response( api(client, "buy", {"voucher": 0}), - "SCHEMA_INVALID_VALUE", + "BAD_REQUEST", "No vouchers to redeem. Defeat boss blind to restock", ) @@ -118,7 +118,7 @@ def test_buy_packs_slot_empty(self, client: socket.socket) -> None: assert gamestate["packs"]["count"] == 0 assert_error_response( api(client, "buy", {"voucher": 0}), - "SCHEMA_INVALID_VALUE", + "BAD_REQUEST", "No vouchers to redeem. Defeat boss blind to restock", ) @@ -174,7 +174,7 @@ def test_invalid_card_type_string(self, client: socket.socket) -> None: assert gamestate["shop"]["cards"][0]["set"] == "JOKER" assert_error_response( api(client, "buy", {"card": "INVALID_STRING"}), - "SCHEMA_INVALID_TYPE", + "BAD_REQUEST", "Field 'card' must be an integer", ) @@ -185,7 +185,7 @@ def test_invalid_voucher_type_string(self, client: socket.socket) -> None: assert gamestate["shop"]["cards"][0]["set"] == "JOKER" assert_error_response( api(client, "buy", {"voucher": "INVALID_STRING"}), - "SCHEMA_INVALID_TYPE", + "BAD_REQUEST", "Field 'voucher' must be an integer", ) @@ -196,7 +196,7 @@ def test_invalid_pack_type_string(self, client: socket.socket) -> None: assert gamestate["shop"]["cards"][0]["set"] == "JOKER" assert_error_response( api(client, "buy", {"pack": "INVALID_STRING"}), - "SCHEMA_INVALID_TYPE", + "BAD_REQUEST", "Field 'pack' must be an integer", ) @@ -210,6 +210,6 @@ def test_buy_from_BLIND_SELECT(self, client: socket.socket) -> None: assert gamestate["state"] == "BLIND_SELECT" assert_error_response( api(client, "buy", {"card": 0}), - "STATE_INVALID_STATE", + "INVALID_STATE", "Endpoint '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 2052d75..25263f9 100644 --- a/tests/lua/endpoints/test_cash_out.py +++ b/tests/lua/endpoints/test_cash_out.py @@ -39,6 +39,6 @@ def test_cash_out_from_BLIND_SELECT(self, client: socket.socket): assert gamestate["state"] == "BLIND_SELECT" assert_error_response( api(client, "cash_out", {}), - "STATE_INVALID_STATE", + "INVALID_STATE", "Endpoint '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 index 9d2b552..c22a10e 100644 --- a/tests/lua/endpoints/test_discard.py +++ b/tests/lua/endpoints/test_discard.py @@ -14,7 +14,7 @@ def test_discard_zero_cards(self, client: socket.socket) -> None: assert gamestate["state"] == "SELECTING_HAND" assert_error_response( api(client, "discard", {"cards": []}), - "SCHEMA_INVALID_VALUE", + "BAD_REQUEST", "Must provide at least one card to discard", ) @@ -24,7 +24,7 @@ def test_discard_too_many_cards(self, client: socket.socket) -> None: assert gamestate["state"] == "SELECTING_HAND" assert_error_response( api(client, "discard", {"cards": [0, 1, 2, 3, 4, 5]}), - "SCHEMA_INVALID_VALUE", + "BAD_REQUEST", "You can only discard 5 cards", ) @@ -34,7 +34,7 @@ def test_discard_out_of_range_cards(self, client: socket.socket) -> None: assert gamestate["state"] == "SELECTING_HAND" assert_error_response( api(client, "discard", {"cards": [999]}), - "SCHEMA_INVALID_VALUE", + "BAD_REQUEST", "Invalid card index: 999", ) @@ -47,7 +47,7 @@ def test_discard_no_discards_left(self, client: socket.socket) -> None: assert gamestate["round"]["discards_left"] == 0 assert_error_response( api(client, "discard", {"cards": [0]}), - "SCHEMA_INVALID_VALUE", + "BAD_REQUEST", "No discards left", ) @@ -83,7 +83,7 @@ def test_missing_cards_parameter(self, client: socket.socket): assert gamestate["state"] == "SELECTING_HAND" assert_error_response( api(client, "discard", {}), - "SCHEMA_MISSING_REQUIRED", + "BAD_REQUEST", "Missing required field 'cards'", ) @@ -93,7 +93,7 @@ def test_invalid_cards_type(self, client: socket.socket): assert gamestate["state"] == "SELECTING_HAND" assert_error_response( api(client, "discard", {"cards": "INVALID_CARDS"}), - "SCHEMA_INVALID_TYPE", + "BAD_REQUEST", "Field 'cards' must be an array", ) @@ -107,6 +107,6 @@ def test_discard_from_BLIND_SELECT(self, client: socket.socket): assert gamestate["state"] == "BLIND_SELECT" assert_error_response( api(client, "discard", {"cards": [0]}), - "STATE_INVALID_STATE", + "INVALID_STATE", "Endpoint 'discard' requires one of these states: SELECTING_HAND", ) diff --git a/tests/lua/endpoints/test_load.py b/tests/lua/endpoints/test_load.py index 7d9ae56..8766370 100644 --- a/tests/lua/endpoints/test_load.py +++ b/tests/lua/endpoints/test_load.py @@ -51,7 +51,7 @@ def test_missing_path_parameter(self, client: socket.socket) -> None: """Test that load fails when path parameter is missing.""" assert_error_response( api(client, "load", {}), - "SCHEMA_MISSING_REQUIRED", + "BAD_REQUEST", "Missing required field 'path'", ) @@ -59,6 +59,6 @@ def test_invalid_path_type(self, client: socket.socket) -> None: """Test that load fails when path is not a string.""" assert_error_response( api(client, "load", {"path": 123}), - "SCHEMA_INVALID_TYPE", + "BAD_REQUEST", "Field 'path' must be of type string", ) diff --git a/tests/lua/endpoints/test_next_round.py b/tests/lua/endpoints/test_next_round.py index dc2f366..5543557 100644 --- a/tests/lua/endpoints/test_next_round.py +++ b/tests/lua/endpoints/test_next_round.py @@ -39,6 +39,6 @@ def test_next_round_from_MENU(self, client: socket.socket): response = api(client, "next_round", {}) assert_error_response( response, - expected_error_code="STATE_INVALID_STATE", + expected_error_code="INVALID_STATE", expected_message_contains="Endpoint 'next_round' requires one of these states: SHOP", ) diff --git a/tests/lua/endpoints/test_play.py b/tests/lua/endpoints/test_play.py index 9fb11a4..a6927fd 100644 --- a/tests/lua/endpoints/test_play.py +++ b/tests/lua/endpoints/test_play.py @@ -14,7 +14,7 @@ def test_play_zero_cards(self, client: socket.socket) -> None: assert gamestate["state"] == "SELECTING_HAND" assert_error_response( api(client, "play", {"cards": []}), - "SCHEMA_INVALID_VALUE", + "BAD_REQUEST", "Must provide at least one card to play", ) @@ -24,7 +24,7 @@ def test_play_six_cards(self, client: socket.socket) -> None: assert gamestate["state"] == "SELECTING_HAND" assert_error_response( api(client, "play", {"cards": [0, 1, 2, 3, 4, 5]}), - "SCHEMA_INVALID_VALUE", + "BAD_REQUEST", "You can only play 5 cards", ) @@ -34,7 +34,7 @@ def test_play_out_of_range_cards(self, client: socket.socket) -> None: assert gamestate["state"] == "SELECTING_HAND" assert_error_response( api(client, "play", {"cards": [999]}), - "SCHEMA_INVALID_VALUE", + "BAD_REQUEST", "Invalid card index: 999", ) @@ -91,7 +91,7 @@ def test_missing_cards_parameter(self, client: socket.socket): assert gamestate["state"] == "SELECTING_HAND" assert_error_response( api(client, "play", {}), - "SCHEMA_MISSING_REQUIRED", + "BAD_REQUEST", "Missing required field 'cards'", ) @@ -101,7 +101,7 @@ def test_invalid_cards_type(self, client: socket.socket): assert gamestate["state"] == "SELECTING_HAND" assert_error_response( api(client, "play", {"cards": "INVALID_CARDS"}), - "SCHEMA_INVALID_TYPE", + "BAD_REQUEST", "Field 'cards' must be an array", ) @@ -115,6 +115,6 @@ def test_play_from_BLIND_SELECT(self, client: socket.socket): assert gamestate["state"] == "BLIND_SELECT" assert_error_response( api(client, "play", {"cards": [0]}), - "STATE_INVALID_STATE", + "INVALID_STATE", "Endpoint 'play' requires one of these states: SELECTING_HAND", ) diff --git a/tests/lua/endpoints/test_rearrange.py b/tests/lua/endpoints/test_rearrange.py index c1b29a9..3a16898 100644 --- a/tests/lua/endpoints/test_rearrange.py +++ b/tests/lua/endpoints/test_rearrange.py @@ -72,7 +72,7 @@ def test_no_parameters_provided(self, client: socket.socket) -> None: response = api(client, "rearrange", {}) assert_error_response( response, - "SCHEMA_INVALID_VALUE", + "BAD_REQUEST", "Must provide exactly one of: hand, jokers, or consumables", ) @@ -87,7 +87,7 @@ def test_multiple_parameters_provided(self, client: socket.socket) -> None: ) assert_error_response( response, - "SCHEMA_INVALID_VALUE", + "BAD_REQUEST", "Can only rearrange one type at a time", ) @@ -105,7 +105,7 @@ def test_wrong_array_length_hand(self, client: socket.socket) -> None: ) assert_error_response( response, - "SCHEMA_INVALID_VALUE", + "BAD_REQUEST", "Must provide exactly 8 indices for hand", ) @@ -123,7 +123,7 @@ def test_wrong_array_length_jokers(self, client: socket.socket) -> None: ) assert_error_response( response, - "SCHEMA_INVALID_VALUE", + "BAD_REQUEST", "Must provide exactly 4 indices for jokers", ) @@ -141,7 +141,7 @@ def test_wrong_array_length_consumables(self, client: socket.socket) -> None: ) assert_error_response( response, - "SCHEMA_INVALID_VALUE", + "BAD_REQUEST", "Must provide exactly 2 indices for consumables", ) @@ -159,7 +159,7 @@ def test_invalid_card_index(self, client: socket.socket) -> None: ) assert_error_response( response, - "SCHEMA_INVALID_VALUE", + "BAD_REQUEST", "Index out of range for hand: -1", ) @@ -177,7 +177,7 @@ def test_duplicate_indices(self, client: socket.socket) -> None: ) assert_error_response( response, - "SCHEMA_INVALID_VALUE", + "BAD_REQUEST", "Duplicate index in hand: 1", ) @@ -191,7 +191,7 @@ def test_rearrange_hand_from_wrong_state(self, client: socket.socket) -> None: assert gamestate["state"] == "BLIND_SELECT" assert_error_response( api(client, "rearrange", {"hand": [0, 1, 2, 3, 4, 5, 6, 7]}), - "STATE_INVALID_STATE", + "INVALID_STATE", "Endpoint 'rearrange' requires one of these states: SELECTING_HAND, SHOP", ) @@ -201,7 +201,7 @@ def test_rearrange_jokers_from_wrong_state(self, client: socket.socket) -> None: assert gamestate["state"] == "BLIND_SELECT" assert_error_response( api(client, "rearrange", {"jokers": [0, 1, 2, 3, 4]}), - "STATE_INVALID_STATE", + "INVALID_STATE", "Endpoint 'rearrange' requires one of these states: SELECTING_HAND, SHOP", ) @@ -213,7 +213,7 @@ def test_rearrange_consumables_from_wrong_state( assert gamestate["state"] == "BLIND_SELECT" assert_error_response( api(client, "rearrange", {"jokers": [0, 1]}), - "STATE_INVALID_STATE", + "INVALID_STATE", "Endpoint 'rearrange' requires one of these states: SELECTING_HAND, SHOP", ) @@ -225,6 +225,6 @@ def test_rearrange_hand_from_shop(self, client: socket.socket) -> None: assert gamestate["state"] == "SHOP" assert_error_response( api(client, "rearrange", {"hand": [0, 1, 2, 3, 4, 5, 6, 7]}), - "STATE_INVALID_STATE", + "INVALID_STATE", "Can only rearrange hand during hand selection", ) diff --git a/tests/lua/endpoints/test_reroll.py b/tests/lua/endpoints/test_reroll.py index 01e869a..08a3785 100644 --- a/tests/lua/endpoints/test_reroll.py +++ b/tests/lua/endpoints/test_reroll.py @@ -24,7 +24,7 @@ def test_reroll_insufficient_funds(self, client: socket.socket) -> None: assert gamestate["money"] == 0 assert_error_response( api(client, "reroll", {}), - "GAME_INVALID_STATE", + "NOT_ALLOWED", "Not enough dollars to reroll", ) @@ -38,6 +38,6 @@ def test_reroll_from_BLIND_SELECT(self, client: socket.socket): assert gamestate["state"] == "BLIND_SELECT" assert_error_response( api(client, "reroll", {}), - "STATE_INVALID_STATE", + "INVALID_STATE", "Endpoint 'reroll' requires one of these states: SHOP", ) diff --git a/tests/lua/endpoints/test_save.py b/tests/lua/endpoints/test_save.py index d69130c..599882e 100644 --- a/tests/lua/endpoints/test_save.py +++ b/tests/lua/endpoints/test_save.py @@ -48,7 +48,7 @@ def test_missing_path_parameter(self, client: socket.socket) -> None: response = api(client, "save", {}) assert_error_response( response, - expected_error_code="SCHEMA_MISSING_REQUIRED", + expected_error_code="BAD_REQUEST", expected_message_contains="Missing required field 'path'", ) @@ -57,7 +57,7 @@ def test_invalid_path_type(self, client: socket.socket) -> None: response = api(client, "save", {"path": 123}) assert_error_response( response, - expected_error_code="SCHEMA_INVALID_TYPE", + expected_error_code="BAD_REQUEST", expected_message_contains="Field 'path' must be of type string", ) @@ -72,7 +72,7 @@ def test_save_from_MENU(self, client: socket.socket, tmp_path: Path) -> None: response = api(client, "save", {"path": str(temp_file)}) assert_error_response( response, - expected_error_code="STATE_INVALID_STATE", + expected_error_code="INVALID_STATE", expected_message_contains="Endpoint '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_select.py b/tests/lua/endpoints/test_select.py index 6fc9464..94385dd 100644 --- a/tests/lua/endpoints/test_select.py +++ b/tests/lua/endpoints/test_select.py @@ -64,6 +64,6 @@ def test_select_from_MENU(self, client: socket.socket): assert response["state"] == "MENU" assert_error_response( api(client, "select", {}), - "STATE_INVALID_STATE", + "INVALID_STATE", "Endpoint 'select' requires one of these states: BLIND_SELECT", ) diff --git a/tests/lua/endpoints/test_sell.py b/tests/lua/endpoints/test_sell.py index 842f1a6..7f0b324 100644 --- a/tests/lua/endpoints/test_sell.py +++ b/tests/lua/endpoints/test_sell.py @@ -16,7 +16,7 @@ def test_sell_no_args(self, client: socket.socket) -> None: assert gamestate["state"] == "SHOP" assert_error_response( api(client, "sell", {}), - "SCHEMA_INVALID_VALUE", + "BAD_REQUEST", "Must provide exactly one of: joker or consumable", ) @@ -28,7 +28,7 @@ def test_sell_multi_args(self, client: socket.socket) -> None: assert gamestate["state"] == "SHOP" assert_error_response( api(client, "sell", {"joker": 0, "consumable": 0}), - "SCHEMA_INVALID_VALUE", + "BAD_REQUEST", "Can only sell one item at a time", ) @@ -41,7 +41,7 @@ def test_sell_no_jokers(self, client: socket.socket) -> None: assert gamestate["jokers"]["count"] == 0 assert_error_response( api(client, "sell", {"joker": 0}), - "GAME_INVALID_STATE", + "NOT_ALLOWED", "No jokers available to sell", ) @@ -54,7 +54,7 @@ def test_sell_no_consumables(self, client: socket.socket) -> None: assert gamestate["consumables"]["count"] == 0 assert_error_response( api(client, "sell", {"consumable": 0}), - "GAME_INVALID_STATE", + "NOT_ALLOWED", "No consumables available to sell", ) @@ -67,7 +67,7 @@ def test_sell_joker_invalid_index(self, client: socket.socket) -> None: assert gamestate["jokers"]["count"] == 1 assert_error_response( api(client, "sell", {"joker": 1}), - "SCHEMA_INVALID_VALUE", + "BAD_REQUEST", "Index out of range for joker: 1", ) @@ -80,7 +80,7 @@ def test_sell_consumable_invalid_index(self, client: socket.socket) -> None: assert gamestate["consumables"]["count"] == 1 assert_error_response( api(client, "sell", {"consumable": 1}), - "SCHEMA_INVALID_VALUE", + "BAD_REQUEST", "Index out of range for consumable: 1", ) @@ -143,7 +143,7 @@ def test_invalid_joker_type_string(self, client: socket.socket) -> None: assert gamestate["jokers"]["count"] == 1 assert_error_response( api(client, "sell", {"joker": "INVALID_STRING"}), - "SCHEMA_INVALID_TYPE", + "BAD_REQUEST", "Field 'joker' must be an integer", ) @@ -156,7 +156,7 @@ def test_invalid_consumable_type_string(self, client: socket.socket) -> None: assert gamestate["consumables"]["count"] == 1 assert_error_response( api(client, "sell", {"consumable": "INVALID_STRING"}), - "SCHEMA_INVALID_TYPE", + "BAD_REQUEST", "Field 'consumable' must be an integer", ) @@ -170,7 +170,7 @@ def test_sell_from_BLIND_SELECT(self, client: socket.socket) -> None: assert gamestate["state"] == "BLIND_SELECT" assert_error_response( api(client, "sell", {}), - "STATE_INVALID_STATE", + "INVALID_STATE", "Endpoint 'sell' requires one of these states: SELECTING_HAND, SHOP", ) @@ -180,6 +180,6 @@ def test_sell_from_ROUND_EVAL(self, client: socket.socket) -> None: assert gamestate["state"] == "ROUND_EVAL" assert_error_response( api(client, "sell", {}), - "STATE_INVALID_STATE", + "INVALID_STATE", "Endpoint 'sell' requires one of these states: SELECTING_HAND, SHOP", ) diff --git a/tests/lua/endpoints/test_set.py b/tests/lua/endpoints/test_set.py index bdc8793..6d1501e 100644 --- a/tests/lua/endpoints/test_set.py +++ b/tests/lua/endpoints/test_set.py @@ -14,7 +14,7 @@ def test_set_game_not_in_run(self, client: socket.socket) -> None: response = api(client, "set", {}) assert_error_response( response, - expected_error_code="GAME_NOT_IN_RUN", + expected_error_code="INVALID_STATE", expected_message_contains="Can only set during an active run", ) @@ -25,7 +25,7 @@ def test_set_no_fields(self, client: socket.socket) -> None: response = api(client, "set", {}) assert_error_response( response, - expected_error_code="SCHEMA_INVALID_VALUE", + expected_error_code="BAD_REQUEST", expected_message_contains="Must provide at least one field to set", ) @@ -36,7 +36,7 @@ def test_set_negative_money(self, client: socket.socket) -> None: response = api(client, "set", {"money": -100}) assert_error_response( response, - expected_error_code="SCHEMA_INVALID_VALUE", + expected_error_code="BAD_REQUEST", expected_message_contains="Money must be a positive integer", ) @@ -54,7 +54,7 @@ def test_set_negative_chips(self, client: socket.socket) -> None: response = api(client, "set", {"chips": -100}) assert_error_response( response, - expected_error_code="SCHEMA_INVALID_VALUE", + expected_error_code="BAD_REQUEST", expected_message_contains="Chips must be a positive integer", ) @@ -72,7 +72,7 @@ def test_set_negative_ante(self, client: socket.socket) -> None: response = api(client, "set", {"ante": -8}) assert_error_response( response, - expected_error_code="SCHEMA_INVALID_VALUE", + expected_error_code="BAD_REQUEST", expected_message_contains="Ante must be a positive integer", ) @@ -90,7 +90,7 @@ def test_set_negative_round(self, client: socket.socket) -> None: response = api(client, "set", {"round": -5}) assert_error_response( response, - expected_error_code="SCHEMA_INVALID_VALUE", + expected_error_code="BAD_REQUEST", expected_message_contains="Round must be a positive integer", ) @@ -108,7 +108,7 @@ def test_set_negative_hands(self, client: socket.socket) -> None: response = api(client, "set", {"hands": -10}) assert_error_response( response, - expected_error_code="SCHEMA_INVALID_VALUE", + expected_error_code="BAD_REQUEST", expected_message_contains="Hands must be a positive integer", ) @@ -126,7 +126,7 @@ def test_set_negative_discards(self, client: socket.socket) -> None: response = api(client, "set", {"discards": -10}) assert_error_response( response, - expected_error_code="SCHEMA_INVALID_VALUE", + expected_error_code="BAD_REQUEST", expected_message_contains="Discards must be a positive integer", ) @@ -144,7 +144,7 @@ def test_set_shop_from_selecting_hand(self, client: socket.socket) -> None: response = api(client, "set", {"shop": True}) assert_error_response( response, - expected_error_code="GAME_INVALID_STATE", + expected_error_code="NOT_ALLOWED", expected_message_contains="Can re-stock shop only in SHOP state", ) @@ -183,7 +183,7 @@ def test_invalid_money_type(self, client: socket.socket): response = api(client, "set", {"money": "INVALID_STRING"}) assert_error_response( response, - expected_error_code="SCHEMA_INVALID_TYPE", + expected_error_code="BAD_REQUEST", expected_message_contains="Field 'money' must be an integer", ) @@ -194,7 +194,7 @@ def test_invalid_chips_type(self, client: socket.socket): response = api(client, "set", {"chips": "INVALID_STRING"}) assert_error_response( response, - expected_error_code="SCHEMA_INVALID_TYPE", + expected_error_code="BAD_REQUEST", expected_message_contains="Field 'chips' must be an integer", ) @@ -205,7 +205,7 @@ def test_invalid_ante_type(self, client: socket.socket): response = api(client, "set", {"ante": "INVALID_STRING"}) assert_error_response( response, - expected_error_code="SCHEMA_INVALID_TYPE", + expected_error_code="BAD_REQUEST", expected_message_contains="Field 'ante' must be an integer", ) @@ -216,7 +216,7 @@ def test_invalid_round_type(self, client: socket.socket): response = api(client, "set", {"round": "INVALID_STRING"}) assert_error_response( response, - expected_error_code="SCHEMA_INVALID_TYPE", + expected_error_code="BAD_REQUEST", expected_message_contains="Field 'round' must be an integer", ) @@ -227,7 +227,7 @@ def test_invalid_hands_type(self, client: socket.socket): response = api(client, "set", {"hands": "INVALID_STRING"}) assert_error_response( response, - expected_error_code="SCHEMA_INVALID_TYPE", + expected_error_code="BAD_REQUEST", expected_message_contains="Field 'hands' must be an integer", ) @@ -238,7 +238,7 @@ def test_invalid_discards_type(self, client: socket.socket): response = api(client, "set", {"discards": "INVALID_STRING"}) assert_error_response( response, - expected_error_code="SCHEMA_INVALID_TYPE", + expected_error_code="BAD_REQUEST", expected_message_contains="Field 'discards' must be an integer", ) @@ -249,6 +249,6 @@ def test_invalid_shop_type(self, client: socket.socket): response = api(client, "set", {"shop": "INVALID_STRING"}) assert_error_response( response, - expected_error_code="SCHEMA_INVALID_TYPE", + expected_error_code="BAD_REQUEST", expected_message_contains="Field 'shop' must be of type boolean", ) diff --git a/tests/lua/endpoints/test_skip.py b/tests/lua/endpoints/test_skip.py index b1bbe56..48da75b 100644 --- a/tests/lua/endpoints/test_skip.py +++ b/tests/lua/endpoints/test_skip.py @@ -54,7 +54,7 @@ def test_skip_big_boss(self, client: socket.socket) -> None: assert gamestate["blinds"]["boss"]["status"] == "SELECT" assert_error_response( api(client, "skip", {}), - "GAME_INVALID_STATE", + "NOT_ALLOWED", "Cannot skip Boss blind", ) @@ -68,6 +68,6 @@ def test_skip_from_MENU(self, client: socket.socket): assert response["state"] == "MENU" assert_error_response( api(client, "skip", {}), - "STATE_INVALID_STATE", + "INVALID_STATE", "Endpoint 'skip' requires one of these states: BLIND_SELECT", ) diff --git a/tests/lua/endpoints/test_start.py b/tests/lua/endpoints/test_start.py index 6f04975..ff8af94 100644 --- a/tests/lua/endpoints/test_start.py +++ b/tests/lua/endpoints/test_start.py @@ -85,7 +85,7 @@ def test_missing_deck_parameter(self, client: socket.socket): response = api(client, "start", {"stake": "WHITE"}) assert_error_response( response, - expected_error_code="SCHEMA_MISSING_REQUIRED", + expected_error_code="BAD_REQUEST", expected_message_contains="Missing required field 'deck'", ) @@ -96,7 +96,7 @@ def test_missing_stake_parameter(self, client: socket.socket): response = api(client, "start", {"deck": "RED"}) assert_error_response( response, - expected_error_code="SCHEMA_MISSING_REQUIRED", + expected_error_code="BAD_REQUEST", expected_message_contains="Missing required field 'stake'", ) @@ -107,7 +107,7 @@ def test_invalid_deck_value(self, client: socket.socket): response = api(client, "start", {"deck": "INVALID_DECK", "stake": "WHITE"}) assert_error_response( response, - expected_error_code="SCHEMA_INVALID_VALUE", + expected_error_code="BAD_REQUEST", expected_message_contains="Invalid deck enum. Must be one of:", ) @@ -118,7 +118,7 @@ def test_invalid_stake_value(self, client: socket.socket): response = api(client, "start", {"deck": "RED", "stake": "INVALID_STAKE"}) assert_error_response( response, - expected_error_code="SCHEMA_INVALID_VALUE", + expected_error_code="BAD_REQUEST", expected_message_contains="Invalid stake enum. Must be one of:", ) @@ -129,7 +129,7 @@ def test_invalid_deck_type(self, client: socket.socket): response = api(client, "start", {"deck": 123, "stake": "WHITE"}) assert_error_response( response, - expected_error_code="SCHEMA_INVALID_TYPE", + expected_error_code="BAD_REQUEST", expected_message_contains="Field 'deck' must be of type string", ) @@ -140,7 +140,7 @@ def test_invalid_stake_type(self, client: socket.socket): response = api(client, "start", {"deck": "RED", "stake": 1}) assert_error_response( response, - expected_error_code="SCHEMA_INVALID_TYPE", + expected_error_code="BAD_REQUEST", expected_message_contains="Field 'stake' must be of type string", ) @@ -155,6 +155,6 @@ def test_start_from_BLIND_SELECT(self, client: socket.socket): response = api(client, "start", {"deck": "RED", "stake": "WHITE"}) assert_error_response( response, - expected_error_code="STATE_INVALID_STATE", + expected_error_code="INVALID_STATE", expected_message_contains="Endpoint 'start' requires one of these states: MENU", ) diff --git a/tests/lua/endpoints/test_use.py b/tests/lua/endpoints/test_use.py index b31aba5..81b853d 100644 --- a/tests/lua/endpoints/test_use.py +++ b/tests/lua/endpoints/test_use.py @@ -110,7 +110,7 @@ def test_use_no_consumable_provided(self, client: socket.socket) -> None: assert gamestate["state"] == "SELECTING_HAND" assert_error_response( api(client, "use", {}), - "SCHEMA_MISSING_REQUIRED", + "BAD_REQUEST", "Missing required field 'consumable'", ) @@ -124,7 +124,7 @@ def test_use_invalid_consumable_type(self, client: socket.socket) -> None: assert gamestate["state"] == "SELECTING_HAND" assert_error_response( api(client, "use", {"consumable": "NOT_AN_INTEGER"}), - "SCHEMA_INVALID_TYPE", + "BAD_REQUEST", "Field 'consumable' must be an integer", ) @@ -138,7 +138,7 @@ def test_use_invalid_consumable_index_negative(self, client: socket.socket) -> N assert gamestate["state"] == "SELECTING_HAND" assert_error_response( api(client, "use", {"consumable": -1}), - "SCHEMA_INVALID_VALUE", + "BAD_REQUEST", "Consumable index out of range: -1", ) @@ -152,7 +152,7 @@ def test_use_invalid_consumable_index_too_high(self, client: socket.socket) -> N assert gamestate["state"] == "SELECTING_HAND" assert_error_response( api(client, "use", {"consumable": 999}), - "SCHEMA_INVALID_VALUE", + "BAD_REQUEST", "Consumable index out of range: 999", ) @@ -166,7 +166,7 @@ def test_use_invalid_cards_type(self, client: socket.socket) -> None: assert gamestate["state"] == "SELECTING_HAND" assert_error_response( api(client, "use", {"consumable": 1, "cards": "NOT_AN_ARRAY_OF_INTEGERS"}), - "SCHEMA_INVALID_TYPE", + "BAD_REQUEST", "Field 'cards' must be an array", ) @@ -180,7 +180,7 @@ def test_use_invalid_cards_item_type(self, client: socket.socket) -> None: assert gamestate["state"] == "SELECTING_HAND" assert_error_response( api(client, "use", {"consumable": 1, "cards": ["NOT_INT_1", "NOT_INT_2"]}), - "SCHEMA_INVALID_ARRAY_ITEMS", + "BAD_REQUEST", "Field 'cards' array item at index 0 must be of type integer", ) @@ -194,7 +194,7 @@ def test_use_invalid_card_index_negative(self, client: socket.socket) -> None: assert gamestate["state"] == "SELECTING_HAND" assert_error_response( api(client, "use", {"consumable": 1, "cards": [-1]}), - "SCHEMA_INVALID_VALUE", + "BAD_REQUEST", "Card index out of range: -1", ) @@ -208,7 +208,7 @@ def test_use_invalid_card_index_too_high(self, client: socket.socket) -> None: assert gamestate["state"] == "SELECTING_HAND" assert_error_response( api(client, "use", {"consumable": 1, "cards": [999]}), - "SCHEMA_INVALID_VALUE", + "BAD_REQUEST", "Card index out of range: 999", ) @@ -223,7 +223,7 @@ def test_use_magician_without_cards(self, client: socket.socket) -> None: assert gamestate["consumables"]["cards"][1]["key"] == "c_magician" assert_error_response( api(client, "use", {"consumable": 1}), - "SCHEMA_MISSING_REQUIRED", + "BAD_REQUEST", "Consumable 'The Magician' requires card selection", ) @@ -238,7 +238,7 @@ def test_use_magician_with_empty_cards(self, client: socket.socket) -> None: assert gamestate["consumables"]["cards"][1]["key"] == "c_magician" assert_error_response( api(client, "use", {"consumable": 1, "cards": []}), - "SCHEMA_MISSING_REQUIRED", + "BAD_REQUEST", "Consumable 'The Magician' requires card selection", ) @@ -253,7 +253,7 @@ def test_use_magician_too_many_cards(self, client: socket.socket) -> None: assert gamestate["consumables"]["cards"][1]["key"] == "c_magician" assert_error_response( api(client, "use", {"consumable": 1, "cards": [0, 1, 2]}), - "SCHEMA_INVALID_VALUE", + "BAD_REQUEST", "Consumable 'The Magician' requires at most 2 cards (provided: 3)", ) @@ -268,7 +268,7 @@ def test_use_death_too_few_cards(self, client: socket.socket) -> None: assert gamestate["consumables"]["cards"][0]["key"] == "c_death" assert_error_response( api(client, "use", {"consumable": 0, "cards": [0]}), - "SCHEMA_INVALID_VALUE", + "BAD_REQUEST", "Consumable 'Death' requires exactly 2 cards (provided: 1)", ) @@ -283,7 +283,7 @@ def test_use_death_too_many_cards(self, client: socket.socket) -> None: assert gamestate["consumables"]["cards"][0]["key"] == "c_death" assert_error_response( api(client, "use", {"consumable": 0, "cards": [0, 1, 2]}), - "SCHEMA_INVALID_VALUE", + "BAD_REQUEST", "Consumable 'Death' requires exactly 2 cards (provided: 3)", ) @@ -301,7 +301,7 @@ def test_use_from_BLIND_SELECT(self, client: socket.socket) -> None: assert gamestate["state"] == "BLIND_SELECT" assert_error_response( api(client, "use", {"consumable": 0, "cards": [0]}), - "STATE_INVALID_STATE", + "INVALID_STATE", "Endpoint 'use' requires one of these states: SELECTING_HAND, SHOP", ) @@ -315,7 +315,7 @@ def test_use_from_ROUND_EVAL(self, client: socket.socket) -> None: assert gamestate["state"] == "ROUND_EVAL" assert_error_response( api(client, "use", {"consumable": 0, "cards": [0]}), - "STATE_INVALID_STATE", + "INVALID_STATE", "Endpoint 'use' requires one of these states: SELECTING_HAND, SHOP", ) @@ -330,7 +330,7 @@ def test_use_magician_from_SHOP(self, client: socket.socket) -> None: assert gamestate["consumables"]["cards"][0]["key"] == "c_magician" assert_error_response( api(client, "use", {"consumable": 0, "cards": [0]}), - "STATE_INVALID_STATE", + "INVALID_STATE", "Consumable 'The Magician' requires card selection and can only be used in SELECTING_HAND state", ) @@ -345,6 +345,6 @@ def test_use_familiar_from_SHOP(self, client: socket.socket) -> None: assert gamestate["consumables"]["cards"][0]["key"] == "c_familiar" assert_error_response( api(client, "use", {"consumable": 0}), - "GAME_INVALID_STATE", + "NOT_ALLOWED", "Consumable 'Familiar' cannot be used at this time", ) From 2a123be1cfa5d875ae1d50500332aed25534bd29 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Thu, 4 Dec 2025 18:01:10 +0100 Subject: [PATCH 168/230] refactor(lua.endpoints): update types for endpoints params --- src/lua/endpoints/add.lua | 6 +++--- src/lua/endpoints/buy.lua | 4 ++-- src/lua/endpoints/discard.lua | 4 ++-- src/lua/endpoints/load.lua | 4 ++-- src/lua/endpoints/play.lua | 4 ++-- src/lua/endpoints/rearrange.lua | 4 ++-- src/lua/endpoints/save.lua | 4 ++-- src/lua/endpoints/sell.lua | 4 ++-- src/lua/endpoints/set.lua | 4 ++-- src/lua/endpoints/start.lua | 4 ++-- src/lua/endpoints/tests/echo.lua | 9 ++++++++- src/lua/endpoints/tests/endpoint.lua | 9 ++++++++- src/lua/endpoints/tests/error.lua | 5 ++++- src/lua/endpoints/tests/state.lua | 7 +++++-- src/lua/endpoints/tests/validation.lua | 11 ++++++++++- src/lua/endpoints/use.lua | 8 +++++--- 16 files changed, 61 insertions(+), 30 deletions(-) diff --git a/src/lua/endpoints/add.lua b/src/lua/endpoints/add.lua index a01e55c..184ffa2 100644 --- a/src/lua/endpoints/add.lua +++ b/src/lua/endpoints/add.lua @@ -3,7 +3,7 @@ -- -- Add a new card to the game using SMODS.add_card ----@class Endpoint.Add.Args +---@class 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) @@ -136,7 +136,7 @@ return { description = "If true, the card will be eternal (cannot be sold or destroyed) - only valid for jokers", }, perishable = { - type = "number", + type = "integer", required = false, description = "Number of rounds before card perishes (must be positive integer >= 1) - only valid for jokers", }, @@ -148,7 +148,7 @@ return { }, requires_state = { G.STATES.SELECTING_HAND, G.STATES.SHOP, G.STATES.ROUND_EVAL }, - ---@param args Endpoint.Add.Args The arguments (key) + ---@param args Endpoint.Add.Params The arguments for the endpoint ---@param send_response fun(response: table) Callback to send response execute = function(args, send_response) sendDebugMessage("Init add()", "BB.ENDPOINTS") diff --git a/src/lua/endpoints/buy.lua b/src/lua/endpoints/buy.lua index d968979..25369b9 100644 --- a/src/lua/endpoints/buy.lua +++ b/src/lua/endpoints/buy.lua @@ -3,7 +3,7 @@ -- -- Buy a card from the shop ----@class Endpoint.Buy.Args +---@class 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 @@ -31,7 +31,7 @@ return { }, requires_state = { G.STATES.SHOP }, - ---@param args Endpoint.Buy.Args The arguments (card) + ---@param args Endpoint.Buy.Params The arguments for the endpoint ---@param send_response fun(response: table) Callback to send response execute = function(args, send_response) sendDebugMessage("Init buy()", "BB.ENDPOINTS") diff --git a/src/lua/endpoints/discard.lua b/src/lua/endpoints/discard.lua index fcf7373..6e24067 100644 --- a/src/lua/endpoints/discard.lua +++ b/src/lua/endpoints/discard.lua @@ -3,7 +3,7 @@ -- -- Discard cards from the hand ----@class Endpoint.Discard.Args +---@class Endpoint.Discard.Params ---@field cards integer[] 0-based indices of cards to discard ---@type Endpoint @@ -20,7 +20,7 @@ return { }, requires_state = { G.STATES.SELECTING_HAND }, - ---@param args Endpoint.Discard.Args The arguments (cards) + ---@param args Endpoint.Discard.Params The arguments (cards) ---@param send_response fun(response: table) Callback to send response execute = function(args, send_response) if #args.cards == 0 then diff --git a/src/lua/endpoints/load.lua b/src/lua/endpoints/load.lua index c395f70..db3fd82 100644 --- a/src/lua/endpoints/load.lua +++ b/src/lua/endpoints/load.lua @@ -5,7 +5,7 @@ local nativefs = require("nativefs") ----@class Endpoint.Load.Args +---@class Endpoint.Load.Params ---@field path string File path to the save file ---@type Endpoint @@ -24,7 +24,7 @@ return { requires_state = nil, - ---@param args Endpoint.Load.Args The arguments with 'path' field + ---@param args Endpoint.Load.Params The arguments with 'path' field ---@param send_response fun(response: table) Callback to send response execute = function(args, send_response) local path = args.path diff --git a/src/lua/endpoints/play.lua b/src/lua/endpoints/play.lua index 9a126ec..fa1d117 100644 --- a/src/lua/endpoints/play.lua +++ b/src/lua/endpoints/play.lua @@ -3,7 +3,7 @@ -- -- Play a card from the hand ----@class Endpoint.Play.Args +---@class Endpoint.Play.Params ---@field cards integer[] 0-based indices of cards to play ---@type Endpoint @@ -20,7 +20,7 @@ return { }, requires_state = { G.STATES.SELECTING_HAND }, - ---@param args Endpoint.Play.Args The arguments (cards) + ---@param args Endpoint.Play.Params The arguments (cards) ---@param send_response fun(response: table) Callback to send response execute = function(args, send_response) if #args.cards == 0 then diff --git a/src/lua/endpoints/rearrange.lua b/src/lua/endpoints/rearrange.lua index 2375e64..f11cbad 100644 --- a/src/lua/endpoints/rearrange.lua +++ b/src/lua/endpoints/rearrange.lua @@ -3,7 +3,7 @@ -- -- Rearrange cards in hand, jokers, or consumables ----@class Endpoint.Rearrange.Args +---@class 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 @@ -35,7 +35,7 @@ return { }, requires_state = { G.STATES.SELECTING_HAND, G.STATES.SHOP }, - ---@param args Endpoint.Rearrange.Args The arguments (hand, jokers, or consumables) + ---@param args Endpoint.Rearrange.Params The arguments (hand, jokers, or consumables) ---@param send_response fun(response: table) Callback to send response execute = function(args, send_response) -- Validate exactly one parameter is provided diff --git a/src/lua/endpoints/save.lua b/src/lua/endpoints/save.lua index 0531a66..3474d04 100644 --- a/src/lua/endpoints/save.lua +++ b/src/lua/endpoints/save.lua @@ -5,7 +5,7 @@ local nativefs = require("nativefs") ----@class Endpoint.Save.Args +---@class Endpoint.Save.Params ---@field path string File path for the save file ---@type Endpoint @@ -41,7 +41,7 @@ return { G.STATES.NEW_ROUND, -- 19 }, - ---@param args Endpoint.Save.Args The arguments with 'path' field + ---@param args Endpoint.Save.Params The arguments with 'path' field ---@param send_response fun(response: table) Callback to send response execute = function(args, send_response) local path = args.path diff --git a/src/lua/endpoints/sell.lua b/src/lua/endpoints/sell.lua index ca6e95c..bbe02e1 100644 --- a/src/lua/endpoints/sell.lua +++ b/src/lua/endpoints/sell.lua @@ -3,7 +3,7 @@ -- -- Sell a joker or consumable from player inventory ----@class Endpoint.Sell.Args +---@class Endpoint.Sell.Params ---@field joker integer? 0-based index of joker to sell ---@field consumable integer? 0-based index of consumable to sell -- One (and only one) parameter is required @@ -27,7 +27,7 @@ return { }, requires_state = { G.STATES.SELECTING_HAND, G.STATES.SHOP }, - ---@param args Endpoint.Sell.Args The arguments (joker or consumable) + ---@param args Endpoint.Sell.Params The arguments (joker or consumable) ---@param send_response fun(response: table) Callback to send response execute = function(args, send_response) sendDebugMessage("Init sell()", "BB.ENDPOINTS") diff --git a/src/lua/endpoints/set.lua b/src/lua/endpoints/set.lua index ad41a5f..04798b4 100644 --- a/src/lua/endpoints/set.lua +++ b/src/lua/endpoints/set.lua @@ -1,6 +1,6 @@ -- Set a in-game value ----@class Endpoint.Set.Args +---@class Endpoint.Set.Params ---@field money integer? New money amount ---@field chips integer? New chips amount ---@field ante integer? New ante number @@ -52,7 +52,7 @@ return { }, requires_state = nil, - ---@param args Endpoint.Set.Args The arguments + ---@param args Endpoint.Set.Params The arguments ---@param send_response fun(response: table) Callback to send response execute = function(args, send_response) sendDebugMessage("Init set()", "BB.ENDPOINTS") diff --git a/src/lua/endpoints/start.lua b/src/lua/endpoints/start.lua index cb92163..eeb1780 100644 --- a/src/lua/endpoints/start.lua +++ b/src/lua/endpoints/start.lua @@ -33,7 +33,7 @@ local STAKE_ENUM_TO_NUMBER = { GOLD = 8, } ----@class Endpoint.Run.Args +---@class Endpoint.Run.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 @@ -64,7 +64,7 @@ return { requires_state = { G.STATES.MENU }, - ---@param args Endpoint.Run.Args The arguments (deck, stake, seed?) + ---@param args Endpoint.Run.Params The arguments (deck, stake, seed?) ---@param send_response fun(response: table) Callback to send response execute = function(args, send_response) sendDebugMessage("Init start()", "BB.ENDPOINTS") diff --git a/src/lua/endpoints/tests/echo.lua b/src/lua/endpoints/tests/echo.lua index 13c053f..32cad1c 100644 --- a/src/lua/endpoints/tests/echo.lua +++ b/src/lua/endpoints/tests/echo.lua @@ -3,6 +3,13 @@ -- -- Simplified endpoint for testing the dispatcher with the simplified validator +---@class TestEndpoint.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 + ---@type Endpoint return { name = "test_endpoint", @@ -46,7 +53,7 @@ return { requires_state = nil, -- Can be called from any state - ---@param args table The validated arguments + ---@param args TestEndpoint.Echo.Params The validated arguments ---@param send_response fun(response: table) Callback to send response execute = function(args, send_response) -- Echo back the received arguments diff --git a/src/lua/endpoints/tests/endpoint.lua b/src/lua/endpoints/tests/endpoint.lua index ca81f80..cca1ff6 100644 --- a/src/lua/endpoints/tests/endpoint.lua +++ b/src/lua/endpoints/tests/endpoint.lua @@ -3,6 +3,13 @@ -- -- Simplified endpoint for testing the dispatcher with the simplified validator +---@class Endpoint.TestEndpoint.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 + ---@type Endpoint return { name = "test_endpoint", @@ -46,7 +53,7 @@ return { requires_state = nil, -- Can be called from any state - ---@param args table The validated arguments + ---@param args Endpoint.TestEndpoint.Params The validated arguments ---@param send_response fun(response: table) Callback to send response execute = function(args, send_response) -- Echo back the received arguments diff --git a/src/lua/endpoints/tests/error.lua b/src/lua/endpoints/tests/error.lua index db8b085..ba74fc6 100644 --- a/src/lua/endpoints/tests/error.lua +++ b/src/lua/endpoints/tests/error.lua @@ -3,6 +3,9 @@ -- -- Used for testing TIER 4: Execution Error Handling +---@class Endpoint.TestErrorEndpoint.Params +---@field error_type "throw_error"|"success" Whether to throw an error or succeed + ---@type Endpoint return { name = "test_error_endpoint", @@ -20,7 +23,7 @@ return { requires_state = nil, - ---@param args table The arguments + ---@param args Endpoint.TestErrorEndpoint.Params The arguments ---@param send_response fun(response: table) Callback to send response execute = function(args, send_response) if args.error_type == "throw_error" then diff --git a/src/lua/endpoints/tests/state.lua b/src/lua/endpoints/tests/state.lua index 0f21508..66082ae 100644 --- a/src/lua/endpoints/tests/state.lua +++ b/src/lua/endpoints/tests/state.lua @@ -3,6 +3,9 @@ -- -- Used for testing TIER 3: Game State Validation +---@class TestEndpoint.State.Params +-- Empty params - this endpoint has no arguments + ---@type Endpoint return { name = "test_state_endpoint", @@ -14,9 +17,9 @@ return { -- This endpoint can only be called from SPLASH or MENU states requires_state = { "SPLASH", "MENU" }, - ---@param _args table The arguments (empty) + ---@param _ TestEndpoint.State.Params The arguments (empty) ---@param send_response fun(response: table) Callback to send response - execute = function(_args, send_response) + execute = function(_, send_response) send_response({ success = true, state_validated = true, diff --git a/src/lua/endpoints/tests/validation.lua b/src/lua/endpoints/tests/validation.lua index e8b5812..d2c4d44 100644 --- a/src/lua/endpoints/tests/validation.lua +++ b/src/lua/endpoints/tests/validation.lua @@ -6,6 +6,15 @@ -- - Required field validation -- - Array item type validation (integer arrays only) +---@class Endpoint.TestValidation.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 + ---@type Endpoint return { name = "test_validation", @@ -56,7 +65,7 @@ return { requires_state = nil, -- Can be called from any state - ---@param args table The validated arguments + ---@param args Endpoint.TestValidation.Params The validated arguments ---@param send_response fun(response: table) Callback to send response execute = function(args, send_response) -- Simply return success with the received arguments diff --git a/src/lua/endpoints/use.lua b/src/lua/endpoints/use.lua index 5de0463..c496d7d 100644 --- a/src/lua/endpoints/use.lua +++ b/src/lua/endpoints/use.lua @@ -2,7 +2,7 @@ -- -- Use a consumable card (Tarot, Planet, or Spectral) with optional target cards ----@class Endpoint.Use.Args +---@class Endpoint.Use.Params ---@field consumable integer 0-based index of consumable to use ---@field cards integer[]? 0-based indices of cards to target @@ -25,6 +25,8 @@ return { }, requires_state = { G.STATES.SELECTING_HAND, G.STATES.SHOP }, + ---@param args Endpoint.Use.Params The arguments + ---@param send_response fun(response: table) Callback to send response execute = function(args, send_response) sendDebugMessage("Init use()", "BB.ENDPOINTS") @@ -64,7 +66,7 @@ return { end -- Validate each card index is in range - for i, card_idx in ipairs(args.cards) do + for _, card_idx in ipairs(args.cards) do if card_idx < 0 or card_idx >= #G.hand.cards then send_response({ error = "Card index out of range: " .. card_idx, @@ -134,7 +136,7 @@ return { end -- Add cards using proper method - for i, card_idx in ipairs(args.cards) do + 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 From a930c904b421215f1a830e09ece32604f1c62ca2 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Thu, 4 Dec 2025 18:02:12 +0100 Subject: [PATCH 169/230] feat(lua.utils): add request/response types --- src/lua/utils/types.lua | 67 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 66 insertions(+), 1 deletion(-) diff --git a/src/lua/utils/types.lua b/src/lua/utils/types.lua index 17498a8..f528696 100644 --- a/src/lua/utils/types.lua +++ b/src/lua/utils/types.lua @@ -98,4 +98,69 @@ ---@field description string Brief description of the endpoint ---@field schema table Schema definition for arguments validation ---@field requires_state string[]? Optional list of required game states ----@field execute fun(args: table, send_response: fun(response: table)) Execute function +---@field execute fun(args: Request.Params, send_response: fun(response: table)) Execute function + +-- ========================================================================== +-- Request Types (JSON-RPC 2.0) +-- ========================================================================== + +---@class Request +---@field jsonrpc "2.0" +---@field method Request.Method Request method name. This corresponse to the endpoint name +---@field params Request.Params Params to use for the requests +---@field id integer|string|nil Request ID + +---@alias Request.Method +---| "echo" | "endpoint" | "error" | "state" | "validation" # Test Endpoints +---| "add" | "buy" | "cash_out" | "discard" | "gamestate" | "health" | "load" +---| "menu" | "next_round" | "play" | "rearrange" | "reroll" | "save" | "select" +---| "sell" | "set" | "skip" | "start" | "use" + +---@alias Request.Params +---| Endpoint.Add.Params +---| Endpoint.Buy.Params +---| Endpoint.Discard.Params +---| Endpoint.Load.Params +---| Endpoint.Play.Params +---| Endpoint.Rearrange.Params +---| Endpoint.Save.Params +---| Endpoint.Sell.Params +---| Endpoint.Set.Params +---| Endpoint.Run.Params +---| Endpoint.Use.Params +---| TestEndpoint.Echo.Params +---| TestEndpoint.Endpoint.Params +---| TestEndpoint.Error.Params +---| TestEndpoint.State.Params +---| TestEndpoint.Validation.Params + +-- ========================================================================== +-- Response Types (JSON-RPC 2.0) +-- ========================================================================== + +---@class PathResponse +---@field success boolean Whether the request was successful +---@field path string Path to the file + +---@class HealthResponse +---@field success boolean Whether the request was successful + +---@alias GameStateResponse GameState + +---@class ResponseSuccess +---@field jsonrpc "2.0" +---@field result HealthResponse | PathResponse | GameStateResponse Response payload +---@field id integer|string|nil Request ID + +---@class ResponseError +---@field jsonrpc "2.0" +---@field error ResponseError.Error Response error +---@field id integer|string|nil Request ID + +---@class ResponseError.Error +---@field code ErrorCode Numeric error code following JSON-RPC 2.0 convention +---@field message string Human-readable error message +---@field data ResponseError.Error.Data + +---@class ResponseError.Error.Data +---@field name ErrorName Semantic error code From 514761137770d278bde8ed8e09eef5e0488b47e0 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Thu, 4 Dec 2025 18:43:44 +0100 Subject: [PATCH 170/230] refactor(lua.utils): rename errors form BB_ERRORS to BB_ERROR_NAMES and BB_ERROR_CODES --- balatrobot.lua | 2 +- src/lua/utils/errors.lua | 38 +++++++++++++++++++++++++++++--------- 2 files changed, 30 insertions(+), 10 deletions(-) diff --git a/balatrobot.lua b/balatrobot.lua index 837af30..917641a 100644 --- a/balatrobot.lua +++ b/balatrobot.lua @@ -52,7 +52,7 @@ 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"))() -BB_ERRORS = assert(SMODS.load_file("src/lua/utils/errors.lua"))() +assert(SMODS.load_file("src/lua/utils/errors.lua"))() -- Initialize Server local server_success = BB_SERVER.init() diff --git a/src/lua/utils/errors.lua b/src/lua/utils/errors.lua index 8155d93..e826e32 100644 --- a/src/lua/utils/errors.lua +++ b/src/lua/utils/errors.lua @@ -1,16 +1,36 @@ -- src/lua/utils/errors.lua --- Error Codes for BalatroBot API +-- Error definitions for BalatroBot API ----@class ErrorCodes ----@field BAD_REQUEST string Client sent invalid data (protocol/parameter errors) ----@field INVALID_STATE string Action not allowed in current game state ----@field NOT_ALLOWED string Game rules prevent this action ----@field INTERNAL_ERROR string Server-side failure (runtime/execution errors) +---@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) ----@type ErrorCodes -return { +---@alias ErrorNames table + +---@alias ErrorCode +---| -32000 # INTERNAL_ERROR +---| -32001 # BAD_REQUEST +---| -32002 # INVALID_STATE +---| -32003 # NOT_ALLOWED + +---@alias ErrorCodes table + +---@type ErrorNames +BB_ERROR_NAMES = { + INTERNAL_ERROR = "INTERNAL_ERROR", BAD_REQUEST = "BAD_REQUEST", INVALID_STATE = "INVALID_STATE", NOT_ALLOWED = "NOT_ALLOWED", - INTERNAL_ERROR = "INTERNAL_ERROR", } + +---@type ErrorCodes +BB_ERROR_CODES = { + INTERNAL_ERROR = -32000, + BAD_REQUEST = -32001, + INVALID_STATE = -32002, + NOT_ALLOWED = -32003, +} + +return BB_ERROR_NAMES, BB_ERROR_CODES From e69c00958c3ef655c96662bd262ffee6fb92a70d Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Thu, 4 Dec 2025 18:44:30 +0100 Subject: [PATCH 171/230] refactor(lua.endpoints): rename BB_ERRORS to BB_ERROR_NAMES --- src/lua/core/dispatcher.lua | 12 +++++------ src/lua/endpoints/add.lua | 30 +++++++++++++------------- src/lua/endpoints/buy.lua | 16 +++++++------- src/lua/endpoints/discard.lua | 8 +++---- src/lua/endpoints/load.lua | 8 +++---- src/lua/endpoints/play.lua | 6 +++--- src/lua/endpoints/rearrange.lua | 18 ++++++++-------- src/lua/endpoints/reroll.lua | 2 +- src/lua/endpoints/save.lua | 6 +++--- src/lua/endpoints/sell.lua | 10 ++++----- src/lua/endpoints/set.lua | 18 ++++++++-------- src/lua/endpoints/skip.lua | 2 +- src/lua/endpoints/start.lua | 6 +++--- src/lua/endpoints/tests/endpoint.lua | 4 ++-- src/lua/endpoints/tests/error.lua | 4 ++-- src/lua/endpoints/tests/validation.lua | 4 ++-- src/lua/endpoints/use.lua | 18 ++++++++-------- 17 files changed, 85 insertions(+), 87 deletions(-) diff --git a/src/lua/core/dispatcher.lua b/src/lua/core/dispatcher.lua index 71f4548..32f9a2d 100644 --- a/src/lua/core/dispatcher.lua +++ b/src/lua/core/dispatcher.lua @@ -18,8 +18,6 @@ ---@type Validator local Validator = assert(SMODS.load_file("src/lua/core/validator.lua"))() ----@type ErrorCodes -local errors = assert(SMODS.load_file("src/lua/utils/errors.lua"))() -- State name lookup cache (built lazily from G.STATES) ---@type table? @@ -201,20 +199,20 @@ function BB_DISPATCHER.dispatch(request) -- Validate request has 'name' field if not request.name or type(request.name) ~= "string" then - BB_DISPATCHER.send_error("Request missing 'name' field", errors.BAD_REQUEST) + BB_DISPATCHER.send_error("Request missing 'name' field", BB_ERROR_NAMES.BAD_REQUEST) return end -- Validate request has 'arguments' field if not request.arguments then - BB_DISPATCHER.send_error("Request missing 'arguments' field", errors.BAD_REQUEST) + BB_DISPATCHER.send_error("Request missing 'arguments' field", BB_ERROR_NAMES.BAD_REQUEST) return end -- Find endpoint local endpoint = BB_DISPATCHER.endpoints[request.name] if not endpoint then - BB_DISPATCHER.send_error("Unknown endpoint: " .. request.name, errors.BAD_REQUEST) + BB_DISPATCHER.send_error("Unknown endpoint: " .. request.name, BB_ERROR_NAMES.BAD_REQUEST) return end @@ -255,7 +253,7 @@ function BB_DISPATCHER.dispatch(request) BB_DISPATCHER.send_error( "Endpoint '" .. request.name .. "' requires one of these states: " .. table.concat(state_names, ", "), - errors.INVALID_STATE + BB_ERROR_NAMES.INVALID_STATE ) return end @@ -283,6 +281,6 @@ function BB_DISPATCHER.dispatch(request) -- Endpoint threw an error local error_message = tostring(exec_error) - BB_DISPATCHER.send_error(error_message, errors.INTERNAL_ERROR) + BB_DISPATCHER.send_error(error_message, BB_ERROR_NAMES.INTERNAL_ERROR) end end diff --git a/src/lua/endpoints/add.lua b/src/lua/endpoints/add.lua index 184ffa2..e0ec347 100644 --- a/src/lua/endpoints/add.lua +++ b/src/lua/endpoints/add.lua @@ -159,7 +159,7 @@ return { if not card_type then send_response({ error = "Invalid card key format. Expected: joker (j_*), consumable (c_*), voucher (v_*), or playing card (SUIT_RANK)", - error_code = BB_ERRORS.BAD_REQUEST, + error_code = BB_ERROR_NAMES.BAD_REQUEST, }) return end @@ -168,7 +168,7 @@ return { if card_type == "playing_card" and G.STATE ~= G.STATES.SELECTING_HAND then send_response({ error = "Playing cards can only be added in SELECTING_HAND state", - error_code = BB_ERRORS.INVALID_STATE, + error_code = BB_ERROR_NAMES.INVALID_STATE, }) return end @@ -177,7 +177,7 @@ return { if card_type == "voucher" and G.STATE ~= G.STATES.SHOP then send_response({ error = "Vouchers can only be added in SHOP state", - error_code = BB_ERRORS.INVALID_STATE, + error_code = BB_ERROR_NAMES.INVALID_STATE, }) return end @@ -186,7 +186,7 @@ return { if args.seal and card_type ~= "playing_card" then send_response({ error = "Seal can only be applied to playing cards", - error_code = BB_ERRORS.BAD_REQUEST, + error_code = BB_ERROR_NAMES.BAD_REQUEST, }) return end @@ -198,7 +198,7 @@ return { if not seal_value then send_response({ error = "Invalid seal value. Expected: RED, BLUE, GOLD, or PURPLE", - error_code = BB_ERRORS.BAD_REQUEST, + error_code = BB_ERROR_NAMES.BAD_REQUEST, }) return end @@ -208,7 +208,7 @@ return { if args.edition and card_type == "voucher" then send_response({ error = "Edition cannot be applied to vouchers", - error_code = BB_ERRORS.BAD_REQUEST, + error_code = BB_ERROR_NAMES.BAD_REQUEST, }) return end @@ -217,7 +217,7 @@ return { if args.edition and card_type == "consumable" and args.edition ~= "NEGATIVE" then send_response({ error = "Consumables can only have NEGATIVE edition", - error_code = BB_ERRORS.BAD_REQUEST, + error_code = BB_ERROR_NAMES.BAD_REQUEST, }) return end @@ -229,7 +229,7 @@ return { if not edition_value then send_response({ error = "Invalid edition value. Expected: HOLO, FOIL, POLYCHROME, or NEGATIVE", - error_code = BB_ERRORS.BAD_REQUEST, + error_code = BB_ERROR_NAMES.BAD_REQUEST, }) return end @@ -239,7 +239,7 @@ return { if args.enhancement and card_type ~= "playing_card" then send_response({ error = "Enhancement can only be applied to playing cards", - error_code = BB_ERRORS.BAD_REQUEST, + error_code = BB_ERROR_NAMES.BAD_REQUEST, }) return end @@ -251,7 +251,7 @@ return { if not enhancement_value then send_response({ error = "Invalid enhancement value. Expected: BONUS, MULT, WILD, GLASS, STEEL, STONE, GOLD, or LUCKY", - error_code = BB_ERRORS.BAD_REQUEST, + error_code = BB_ERROR_NAMES.BAD_REQUEST, }) return end @@ -261,7 +261,7 @@ return { if args.eternal and card_type ~= "joker" then send_response({ error = "Eternal can only be applied to jokers", - error_code = BB_ERRORS.BAD_REQUEST, + error_code = BB_ERROR_NAMES.BAD_REQUEST, }) return end @@ -270,7 +270,7 @@ return { if args.perishable and card_type ~= "joker" then send_response({ error = "Perishable can only be applied to jokers", - error_code = BB_ERRORS.BAD_REQUEST, + error_code = BB_ERROR_NAMES.BAD_REQUEST, }) return end @@ -280,7 +280,7 @@ return { if type(args.perishable) ~= "number" or args.perishable ~= math.floor(args.perishable) or args.perishable < 1 then send_response({ error = "Perishable must be a positive integer (>= 1)", - error_code = BB_ERRORS.BAD_REQUEST, + error_code = BB_ERROR_NAMES.BAD_REQUEST, }) return end @@ -290,7 +290,7 @@ return { if args.rental and card_type ~= "joker" then send_response({ error = "Rental can only be applied to jokers", - error_code = BB_ERRORS.BAD_REQUEST, + error_code = BB_ERROR_NAMES.BAD_REQUEST, }) return end @@ -371,7 +371,7 @@ return { if not success then send_response({ error = "Failed to add card: " .. args.key, - error_code = BB_ERRORS.BAD_REQUEST, + error_code = BB_ERROR_NAMES.BAD_REQUEST, }) return end diff --git a/src/lua/endpoints/buy.lua b/src/lua/endpoints/buy.lua index 25369b9..3f8fc9d 100644 --- a/src/lua/endpoints/buy.lua +++ b/src/lua/endpoints/buy.lua @@ -64,7 +64,7 @@ return { if not area then send_response({ error = "Invalid arguments. You must provide one of: card, voucher, pack", - error_code = BB_ERRORS.BAD_REQUEST, + error_code = BB_ERROR_NAMES.BAD_REQUEST, }) return end @@ -73,7 +73,7 @@ return { if set > 1 then send_response({ error = "Invalid arguments. Cannot provide more than one of: card, voucher, or pack", - error_code = BB_ERRORS.BAD_REQUEST, + error_code = BB_ERROR_NAMES.BAD_REQUEST, }) return end @@ -90,7 +90,7 @@ return { end send_response({ error = msg, - error_code = BB_ERRORS.BAD_REQUEST, + error_code = BB_ERROR_NAMES.BAD_REQUEST, }) return end @@ -99,7 +99,7 @@ return { if not area.cards[pos] then send_response({ error = "Card index out of range. Index: " .. args.card .. ", Available cards: " .. area.count, - error_code = BB_ERRORS.BAD_REQUEST, + error_code = BB_ERROR_NAMES.BAD_REQUEST, }) return end @@ -111,7 +111,7 @@ return { if card.cost.buy > G.GAME.dollars then send_response({ error = "Card is not affordable. Cost: " .. card.cost.buy .. ", Current money: " .. gamestate.money, - error_code = BB_ERRORS.BAD_REQUEST, + error_code = BB_ERROR_NAMES.BAD_REQUEST, }) return end @@ -124,7 +124,7 @@ return { .. gamestate.jokers.count .. ", Limit: " .. gamestate.jokers.limit, - error_code = BB_ERRORS.BAD_REQUEST, + error_code = BB_ERROR_NAMES.BAD_REQUEST, }) return end @@ -138,7 +138,7 @@ return { .. gamestate.consumables.count .. ", Limit: " .. gamestate.consumables.limit, - error_code = BB_ERRORS.BAD_REQUEST, + error_code = BB_ERROR_NAMES.BAD_REQUEST, }) return end @@ -173,7 +173,7 @@ return { if not btn then send_response({ error = "No buy button found for card", - error_code = BB_ERRORS.NOT_ALLOWED, + error_code = BB_ERROR_NAMES.NOT_ALLOWED, }) return end diff --git a/src/lua/endpoints/discard.lua b/src/lua/endpoints/discard.lua index 6e24067..7725a74 100644 --- a/src/lua/endpoints/discard.lua +++ b/src/lua/endpoints/discard.lua @@ -26,7 +26,7 @@ return { if #args.cards == 0 then send_response({ error = "Must provide at least one card to discard", - error_code = BB_ERRORS.BAD_REQUEST, + error_code = BB_ERROR_NAMES.BAD_REQUEST, }) return end @@ -34,7 +34,7 @@ return { if G.GAME.current_round.discards_left <= 0 then send_response({ error = "No discards left", - error_code = BB_ERRORS.BAD_REQUEST, + error_code = BB_ERROR_NAMES.BAD_REQUEST, }) return end @@ -42,7 +42,7 @@ return { if #args.cards > G.hand.config.highlighted_limit then send_response({ error = "You can only discard " .. G.hand.config.highlighted_limit .. " cards", - error_code = BB_ERRORS.BAD_REQUEST, + error_code = BB_ERROR_NAMES.BAD_REQUEST, }) return end @@ -51,7 +51,7 @@ return { if not G.hand.cards[card_index + 1] then send_response({ error = "Invalid card index: " .. card_index, - error_code = BB_ERRORS.BAD_REQUEST, + error_code = BB_ERROR_NAMES.BAD_REQUEST, }) return end diff --git a/src/lua/endpoints/load.lua b/src/lua/endpoints/load.lua index db3fd82..ee2fecc 100644 --- a/src/lua/endpoints/load.lua +++ b/src/lua/endpoints/load.lua @@ -34,7 +34,7 @@ return { if not file_info or file_info.type ~= "file" then send_response({ error = "File not found: '" .. path .. "'", - error_code = BB_ERRORS.INTERNAL_ERROR, + error_code = BB_ERROR_NAMES.INTERNAL_ERROR, }) return end @@ -45,7 +45,7 @@ return { if not compressed_data then send_response({ error = "Failed to read save file", - error_code = BB_ERRORS.INTERNAL_ERROR, + error_code = BB_ERROR_NAMES.INTERNAL_ERROR, }) return end @@ -59,7 +59,7 @@ return { if not write_success then send_response({ error = "Failed to prepare save file for loading", - error_code = BB_ERRORS.INTERNAL_ERROR, + error_code = BB_ERROR_NAMES.INTERNAL_ERROR, }) return end @@ -71,7 +71,7 @@ return { if G.SAVED_GAME == nil then send_response({ error = "Invalid save file format", - error_code = BB_ERRORS.INTERNAL_ERROR, + error_code = BB_ERROR_NAMES.INTERNAL_ERROR, }) love.filesystem.remove(temp_filename) return diff --git a/src/lua/endpoints/play.lua b/src/lua/endpoints/play.lua index fa1d117..6e22fe7 100644 --- a/src/lua/endpoints/play.lua +++ b/src/lua/endpoints/play.lua @@ -26,7 +26,7 @@ return { if #args.cards == 0 then send_response({ error = "Must provide at least one card to play", - error_code = BB_ERRORS.BAD_REQUEST, + error_code = BB_ERROR_NAMES.BAD_REQUEST, }) return end @@ -34,7 +34,7 @@ return { if #args.cards > G.hand.config.highlighted_limit then send_response({ error = "You can only play " .. G.hand.config.highlighted_limit .. " cards", - error_code = BB_ERRORS.BAD_REQUEST, + error_code = BB_ERROR_NAMES.BAD_REQUEST, }) return end @@ -43,7 +43,7 @@ return { if not G.hand.cards[card_index + 1] then send_response({ error = "Invalid card index: " .. card_index, - error_code = BB_ERRORS.BAD_REQUEST, + error_code = BB_ERROR_NAMES.BAD_REQUEST, }) return end diff --git a/src/lua/endpoints/rearrange.lua b/src/lua/endpoints/rearrange.lua index f11cbad..d8499e7 100644 --- a/src/lua/endpoints/rearrange.lua +++ b/src/lua/endpoints/rearrange.lua @@ -43,13 +43,13 @@ return { if param_count == 0 then send_response({ error = "Must provide exactly one of: hand, jokers, or consumables", - error_code = BB_ERRORS.BAD_REQUEST, + error_code = BB_ERROR_NAMES.BAD_REQUEST, }) return elseif param_count > 1 then send_response({ error = "Can only rearrange one type at a time", - error_code = BB_ERRORS.BAD_REQUEST, + error_code = BB_ERROR_NAMES.BAD_REQUEST, }) return end @@ -62,7 +62,7 @@ return { if G.STATE ~= G.STATES.SELECTING_HAND then send_response({ error = "Can only rearrange hand during hand selection", - error_code = BB_ERRORS.INVALID_STATE, + error_code = BB_ERROR_NAMES.INVALID_STATE, }) return end @@ -71,7 +71,7 @@ return { if not G.hand or not G.hand.cards then send_response({ error = "No hand available to rearrange", - error_code = BB_ERRORS.NOT_ALLOWED, + error_code = BB_ERROR_NAMES.NOT_ALLOWED, }) return end @@ -85,7 +85,7 @@ return { if not G.jokers or not G.jokers.cards then send_response({ error = "No jokers available to rearrange", - error_code = BB_ERRORS.NOT_ALLOWED, + error_code = BB_ERROR_NAMES.NOT_ALLOWED, }) return end @@ -99,7 +99,7 @@ return { if not G.consumeables or not G.consumeables.cards then send_response({ error = "No consumables available to rearrange", - error_code = BB_ERRORS.NOT_ALLOWED, + error_code = BB_ERROR_NAMES.NOT_ALLOWED, }) return end @@ -117,7 +117,7 @@ return { if #indices ~= #source_array then send_response({ error = "Must provide exactly " .. #source_array .. " indices for " .. type_name, - error_code = BB_ERRORS.BAD_REQUEST, + error_code = BB_ERROR_NAMES.BAD_REQUEST, }) return end @@ -129,7 +129,7 @@ return { if idx < 0 or idx >= #source_array then send_response({ error = "Index out of range for " .. type_name .. ": " .. idx, - error_code = BB_ERRORS.BAD_REQUEST, + error_code = BB_ERROR_NAMES.BAD_REQUEST, }) return end @@ -138,7 +138,7 @@ return { if seen[idx] then send_response({ error = "Duplicate index in " .. type_name .. ": " .. idx, - error_code = BB_ERRORS.BAD_REQUEST, + error_code = BB_ERROR_NAMES.BAD_REQUEST, }) return end diff --git a/src/lua/endpoints/reroll.lua b/src/lua/endpoints/reroll.lua index 09df74b..ce54856 100644 --- a/src/lua/endpoints/reroll.lua +++ b/src/lua/endpoints/reroll.lua @@ -22,7 +22,7 @@ return { if G.GAME.dollars < reroll_cost then send_response({ error = "Not enough dollars to reroll. Current: " .. G.GAME.dollars .. ", Required: " .. reroll_cost, - error_code = BB_ERRORS.NOT_ALLOWED, + error_code = BB_ERROR_NAMES.NOT_ALLOWED, }) return end diff --git a/src/lua/endpoints/save.lua b/src/lua/endpoints/save.lua index 3474d04..36bbd9e 100644 --- a/src/lua/endpoints/save.lua +++ b/src/lua/endpoints/save.lua @@ -50,7 +50,7 @@ return { if not G.STAGE or G.STAGE ~= G.STAGES.RUN then send_response({ error = "Can only save during an active run", - error_code = BB_ERRORS.INVALID_STATE, + error_code = BB_ERROR_NAMES.INVALID_STATE, }) return end @@ -70,7 +70,7 @@ return { if not compressed_data then send_response({ error = "Failed to save game state", - error_code = BB_ERRORS.INTERNAL_ERROR, + error_code = BB_ERROR_NAMES.INTERNAL_ERROR, }) return end @@ -79,7 +79,7 @@ return { if not write_success then send_response({ error = "Failed to write save file to '" .. path .. "'", - error_code = BB_ERRORS.INTERNAL_ERROR, + error_code = BB_ERROR_NAMES.INTERNAL_ERROR, }) return end diff --git a/src/lua/endpoints/sell.lua b/src/lua/endpoints/sell.lua index bbe02e1..0226527 100644 --- a/src/lua/endpoints/sell.lua +++ b/src/lua/endpoints/sell.lua @@ -37,13 +37,13 @@ return { if param_count == 0 then send_response({ error = "Must provide exactly one of: joker or consumable", - error_code = BB_ERRORS.BAD_REQUEST, + error_code = BB_ERROR_NAMES.BAD_REQUEST, }) return elseif param_count > 1 then send_response({ error = "Can only sell one item at a time", - error_code = BB_ERRORS.BAD_REQUEST, + error_code = BB_ERROR_NAMES.BAD_REQUEST, }) return end @@ -56,7 +56,7 @@ return { if not G.jokers or not G.jokers.config or G.jokers.config.card_count == 0 then send_response({ error = "No jokers available to sell", - error_code = BB_ERRORS.NOT_ALLOWED, + error_code = BB_ERROR_NAMES.NOT_ALLOWED, }) return end @@ -68,7 +68,7 @@ return { if not G.consumeables or not G.consumeables.config or G.consumeables.config.card_count == 0 then send_response({ error = "No consumables available to sell", - error_code = BB_ERRORS.NOT_ALLOWED, + error_code = BB_ERROR_NAMES.NOT_ALLOWED, }) return end @@ -81,7 +81,7 @@ return { if not source_array[pos] then send_response({ error = "Index out of range for " .. sell_type .. ": " .. (pos - 1), - error_code = BB_ERRORS.BAD_REQUEST, + error_code = BB_ERROR_NAMES.BAD_REQUEST, }) return end diff --git a/src/lua/endpoints/set.lua b/src/lua/endpoints/set.lua index 04798b4..f44ea90 100644 --- a/src/lua/endpoints/set.lua +++ b/src/lua/endpoints/set.lua @@ -61,7 +61,7 @@ return { if G.STAGE and G.STAGE ~= G.STAGES.RUN then send_response({ error = "Can only set during an active run", - error_code = BB_ERRORS.INVALID_STATE, + error_code = BB_ERROR_NAMES.INVALID_STATE, }) return end @@ -78,7 +78,7 @@ return { then send_response({ error = "Must provide at least one field to set", - error_code = BB_ERRORS.BAD_REQUEST, + error_code = BB_ERROR_NAMES.BAD_REQUEST, }) return end @@ -88,7 +88,7 @@ return { if args.money < 0 then send_response({ error = "Money must be a positive integer", - error_code = BB_ERRORS.BAD_REQUEST, + error_code = BB_ERROR_NAMES.BAD_REQUEST, }) return end @@ -101,7 +101,7 @@ return { if args.chips < 0 then send_response({ error = "Chips must be a positive integer", - error_code = BB_ERRORS.BAD_REQUEST, + error_code = BB_ERROR_NAMES.BAD_REQUEST, }) return end @@ -114,7 +114,7 @@ return { if args.ante < 0 then send_response({ error = "Ante must be a positive integer", - error_code = BB_ERRORS.BAD_REQUEST, + error_code = BB_ERROR_NAMES.BAD_REQUEST, }) return end @@ -127,7 +127,7 @@ return { if args.round < 0 then send_response({ error = "Round must be a positive integer", - error_code = BB_ERRORS.BAD_REQUEST, + error_code = BB_ERROR_NAMES.BAD_REQUEST, }) return end @@ -140,7 +140,7 @@ return { if args.hands < 0 then send_response({ error = "Hands must be a positive integer", - error_code = BB_ERRORS.BAD_REQUEST, + error_code = BB_ERROR_NAMES.BAD_REQUEST, }) return end @@ -153,7 +153,7 @@ return { if args.discards < 0 then send_response({ error = "Discards must be a positive integer", - error_code = BB_ERRORS.BAD_REQUEST, + error_code = BB_ERROR_NAMES.BAD_REQUEST, }) return end @@ -165,7 +165,7 @@ return { if G.STATE ~= G.STATES.SHOP then send_response({ error = "Can re-stock shop only in SHOP state", - error_code = BB_ERRORS.NOT_ALLOWED, + error_code = BB_ERROR_NAMES.NOT_ALLOWED, }) return end diff --git a/src/lua/endpoints/skip.lua b/src/lua/endpoints/skip.lua index 1602fa6..9df64ff 100644 --- a/src/lua/endpoints/skip.lua +++ b/src/lua/endpoints/skip.lua @@ -26,7 +26,7 @@ return { sendDebugMessage("skip() cannot skip Boss blind: " .. current_blind, "BB.ENDPOINTS") send_response({ error = "Cannot skip Boss blind", - error_code = BB_ERRORS.NOT_ALLOWED, + error_code = BB_ERROR_NAMES.NOT_ALLOWED, }) return end diff --git a/src/lua/endpoints/start.lua b/src/lua/endpoints/start.lua index eeb1780..48121ac 100644 --- a/src/lua/endpoints/start.lua +++ b/src/lua/endpoints/start.lua @@ -76,7 +76,7 @@ return { send_response({ error = "Invalid stake enum. Must be one of: WHITE, RED, GREEN, BLACK, BLUE, PURPLE, ORANGE, GOLD. Got: " .. tostring(args.stake), - error_code = BB_ERRORS.BAD_REQUEST, + error_code = BB_ERROR_NAMES.BAD_REQUEST, }) return end @@ -88,7 +88,7 @@ return { send_response({ error = "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), - error_code = BB_ERRORS.BAD_REQUEST, + error_code = BB_ERROR_NAMES.BAD_REQUEST, }) return end @@ -115,7 +115,7 @@ return { sendDebugMessage("start() deck not found in game data: " .. deck_name, "BB.ENDPOINTS") send_response({ error = "Deck not found in game data: " .. deck_name, - error_code = BB_ERRORS.INTERNAL_ERROR, + error_code = BB_ERROR_NAMES.INTERNAL_ERROR, }) return end diff --git a/src/lua/endpoints/tests/endpoint.lua b/src/lua/endpoints/tests/endpoint.lua index cca1ff6..9ed735a 100644 --- a/src/lua/endpoints/tests/endpoint.lua +++ b/src/lua/endpoints/tests/endpoint.lua @@ -3,7 +3,7 @@ -- -- Simplified endpoint for testing the dispatcher with the simplified validator ----@class Endpoint.TestEndpoint.Params +---@class TestEndpoint.Endpoint.Params ---@field required_string string A required string field ---@field optional_string? string Optional string field ---@field required_integer integer Required integer field @@ -53,7 +53,7 @@ return { requires_state = nil, -- Can be called from any state - ---@param args Endpoint.TestEndpoint.Params The validated arguments + ---@param args TestEndpoint.Endpoint.Params The validated arguments ---@param send_response fun(response: table) Callback to send response execute = function(args, send_response) -- Echo back the received arguments diff --git a/src/lua/endpoints/tests/error.lua b/src/lua/endpoints/tests/error.lua index ba74fc6..19298fb 100644 --- a/src/lua/endpoints/tests/error.lua +++ b/src/lua/endpoints/tests/error.lua @@ -3,7 +3,7 @@ -- -- Used for testing TIER 4: Execution Error Handling ----@class Endpoint.TestErrorEndpoint.Params +---@class TestEndpoint.Error.Params ---@field error_type "throw_error"|"success" Whether to throw an error or succeed ---@type Endpoint @@ -23,7 +23,7 @@ return { requires_state = nil, - ---@param args Endpoint.TestErrorEndpoint.Params The arguments + ---@param args TestEndpoint.Error.Params The arguments ---@param send_response fun(response: table) Callback to send response execute = function(args, send_response) if args.error_type == "throw_error" then diff --git a/src/lua/endpoints/tests/validation.lua b/src/lua/endpoints/tests/validation.lua index d2c4d44..12cbffc 100644 --- a/src/lua/endpoints/tests/validation.lua +++ b/src/lua/endpoints/tests/validation.lua @@ -6,7 +6,7 @@ -- - Required field validation -- - Array item type validation (integer arrays only) ----@class Endpoint.TestValidation.Params +---@class TestEndpoint.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 @@ -65,7 +65,7 @@ return { requires_state = nil, -- Can be called from any state - ---@param args Endpoint.TestValidation.Params The validated arguments + ---@param args TestEndpoint.Validation.Params The validated arguments ---@param send_response fun(response: table) Callback to send response execute = function(args, send_response) -- Simply return success with the received arguments diff --git a/src/lua/endpoints/use.lua b/src/lua/endpoints/use.lua index c496d7d..25e1c8b 100644 --- a/src/lua/endpoints/use.lua +++ b/src/lua/endpoints/use.lua @@ -34,7 +34,7 @@ return { if args.consumable < 0 or args.consumable >= #G.consumeables.cards then send_response({ error = "Consumable index out of range: " .. args.consumable, - error_code = BB_ERRORS.BAD_REQUEST, + error_code = BB_ERROR_NAMES.BAD_REQUEST, }) return end @@ -50,7 +50,7 @@ return { error = "Consumable '" .. consumable_card.ability.name .. "' requires card selection and can only be used in SELECTING_HAND state", - error_code = BB_ERRORS.INVALID_STATE, + error_code = BB_ERROR_NAMES.INVALID_STATE, }) return end @@ -60,7 +60,7 @@ return { if not args.cards or #args.cards == 0 then send_response({ error = "Consumable '" .. consumable_card.ability.name .. "' requires card selection", - error_code = BB_ERRORS.BAD_REQUEST, + error_code = BB_ERROR_NAMES.BAD_REQUEST, }) return end @@ -70,7 +70,7 @@ return { if card_idx < 0 or card_idx >= #G.hand.cards then send_response({ error = "Card index out of range: " .. card_idx, - error_code = BB_ERRORS.BAD_REQUEST, + error_code = BB_ERROR_NAMES.BAD_REQUEST, }) return end @@ -93,7 +93,7 @@ return { min_cards == 1 and "" or "s", card_count ), - error_code = BB_ERRORS.BAD_REQUEST, + error_code = BB_ERROR_NAMES.BAD_REQUEST, }) return end @@ -108,7 +108,7 @@ return { min_cards == 1 and "" or "s", card_count ), - error_code = BB_ERRORS.BAD_REQUEST, + error_code = BB_ERROR_NAMES.BAD_REQUEST, }) return end @@ -122,7 +122,7 @@ return { max_cards == 1 and "" or "s", card_count ), - error_code = BB_ERRORS.BAD_REQUEST, + error_code = BB_ERROR_NAMES.BAD_REQUEST, }) return end @@ -151,7 +151,7 @@ return { if not consumable_card:can_use_consumeable() then send_response({ error = "Consumable '" .. consumable_card.ability.name .. "' cannot be used at this time", - error_code = BB_ERRORS.NOT_ALLOWED, + error_code = BB_ERROR_NAMES.NOT_ALLOWED, }) return end @@ -160,7 +160,7 @@ return { if consumable_card:check_use() then send_response({ error = "Cannot use consumable '" .. consumable_card.ability.name .. "': insufficient space", - error_code = BB_ERRORS.NOT_ALLOWED, + error_code = BB_ERROR_NAMES.NOT_ALLOWED, }) return end From 89c71dbb7bba62100ece796007463ed2b7304635 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Thu, 4 Dec 2025 18:45:11 +0100 Subject: [PATCH 172/230] fix(lua.endpoints): fix test for `add` params validation types --- tests/lua/endpoints/test_add.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/tests/lua/endpoints/test_add.py b/tests/lua/endpoints/test_add.py index 8a914a3..0f72a95 100644 --- a/tests/lua/endpoints/test_add.py +++ b/tests/lua/endpoints/test_add.py @@ -486,9 +486,9 @@ def test_add_joker_with_eternal_and_perishable(self, client: socket.socket) -> N assert response["jokers"]["cards"][0]["modifier"]["eternal"] is True assert response["jokers"]["cards"][0]["modifier"]["perishable"] == 5 - @pytest.mark.parametrize("invalid_value", [0, -1, 1.5]) - def test_add_joker_with_perishable_invalid_value_fails( - self, client: socket.socket, invalid_value: int | float | str + @pytest.mark.parametrize("invalid_value", [0, -1]) + def test_add_joker_with_perishable_invalid_integer_fails( + self, client: socket.socket, invalid_value: int ) -> None: """Test that invalid perishable values (zero, negative, float) are rejected.""" gamestate = load_fixture( @@ -505,8 +505,9 @@ def test_add_joker_with_perishable_invalid_value_fails( "Perishable must be a positive integer (>= 1)", ) - def test_add_joker_with_perishable_string_fails( - self, client: socket.socket + @pytest.mark.parametrize("invalid_value", [1.5, "NOT_INT_1"]) + def test_add_joker_with_perishable_invalid_type_fails( + self, client: socket.socket, invalid_value: float | str ) -> None: """Test that perishable with string value is rejected.""" gamestate = load_fixture( @@ -516,11 +517,11 @@ def test_add_joker_with_perishable_string_fails( ) assert gamestate["state"] == "SHOP" assert gamestate["jokers"]["count"] == 0 - response = api(client, "add", {"key": "j_joker", "perishable": "NOT_INT_1"}) + response = api(client, "add", {"key": "j_joker", "perishable": invalid_value}) assert_error_response( response, "BAD_REQUEST", - "Field 'perishable' must be of type number", + "Field 'perishable' must be an integer", ) @pytest.mark.parametrize("key", ["c_fool", "v_overstock_norm"]) From 4714b917b604c2cb671b97ed57dafb901a3ed609 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Thu, 4 Dec 2025 20:06:23 +0100 Subject: [PATCH 173/230] chore: rewrite CLAUDE.md for BalatroBot v1 --- CLAUDE.md | 185 ++++++++++++++++++++++++++++++------------------------ 1 file changed, 102 insertions(+), 83 deletions(-) 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 From 5e494b1e6eef432d9d4fd5b1605526cb1e1fe04f Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Thu, 4 Dec 2025 23:04:20 +0100 Subject: [PATCH 174/230] fix(lua.utils): set the proper type for requires_state (`integer[]?`) --- src/lua/utils/types.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lua/utils/types.lua b/src/lua/utils/types.lua index f528696..41a3856 100644 --- a/src/lua/utils/types.lua +++ b/src/lua/utils/types.lua @@ -97,7 +97,7 @@ ---@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 string[]? Optional list of required game states +---@field requires_state integer[]? Optional list of required game states ---@field execute fun(args: Request.Params, send_response: fun(response: table)) Execute function -- ========================================================================== From 0e154d4bd1d5a4fb98518ff0a35f56bb5e7278ee Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Thu, 4 Dec 2025 23:05:02 +0100 Subject: [PATCH 175/230] chore(lua.settings): remove nil args from love.graphics.setShader --- src/lua/settings.lua | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/lua/settings.lua b/src/lua/settings.lua index a2544be..9fb797d 100644 --- a/src/lua/settings.lua +++ b/src/lua/settings.lua @@ -201,8 +201,8 @@ end ---@return nil local function configure_no_shaders() local love_graphics_setShader = love.graphics.setShader - love.graphics.setShader = function(_) - return love_graphics_setShader(nil) + love.graphics.setShader = function() + return love_graphics_setShader() end sendDebugMessage("Disabled all shaders", "BB.SETTINGS") end From 817416813e4923bbb435c6b26fa4fcb58918f158 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Thu, 4 Dec 2025 23:05:29 +0100 Subject: [PATCH 176/230] fix(lua.utils): use Card.Value.Rank|Suit instead Rank|Suit --- src/lua/utils/gamestate.lua | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/lua/utils/gamestate.lua b/src/lua/utils/gamestate.lua index 0e6116c..8d4e3a7 100644 --- a/src/lua/utils/gamestate.lua +++ b/src/lua/utils/gamestate.lua @@ -126,7 +126,7 @@ end ---Converts Balatro suit name to enum format ---@param suit_name string The suit name from card.config.card.suit ----@return Suit? suit_enum The single-letter suit enum ("H", "D", "C", "S") +---@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" @@ -142,7 +142,7 @@ end ---Converts Balatro rank value to enum format ---@param rank_value string The rank value from card.config.card.value ----@return Rank? rank_enum The single-character rank enum +---@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 From 09f49a56f1ad9b5462fe0c88fa7aadd9ae7e982f Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Thu, 4 Dec 2025 23:06:59 +0100 Subject: [PATCH 177/230] chore(lua.endpoints): update expected error messages in tests --- tests/lua/endpoints/test_add.py | 2 +- tests/lua/endpoints/test_buy.py | 2 +- tests/lua/endpoints/test_cash_out.py | 2 +- tests/lua/endpoints/test_discard.py | 2 +- tests/lua/endpoints/test_next_round.py | 4 +- tests/lua/endpoints/test_play.py | 2 +- tests/lua/endpoints/test_rearrange.py | 6 +-- tests/lua/endpoints/test_reroll.py | 2 +- tests/lua/endpoints/test_save.py | 12 ++--- tests/lua/endpoints/test_select.py | 2 +- tests/lua/endpoints/test_sell.py | 4 +- tests/lua/endpoints/test_set.py | 64 +++++++++++++------------- tests/lua/endpoints/test_skip.py | 2 +- tests/lua/endpoints/test_start.py | 28 +++++------ tests/lua/endpoints/test_use.py | 4 +- 15 files changed, 69 insertions(+), 69 deletions(-) diff --git a/tests/lua/endpoints/test_add.py b/tests/lua/endpoints/test_add.py index 0f72a95..2164547 100644 --- a/tests/lua/endpoints/test_add.py +++ b/tests/lua/endpoints/test_add.py @@ -159,7 +159,7 @@ def test_add_from_BLIND_SELECT(self, client: socket.socket) -> None: assert_error_response( api(client, "add", {"key": "j_joker"}), "INVALID_STATE", - "Endpoint 'add' requires one of these states: SELECTING_HAND, SHOP, ROUND_EVAL", + "Method 'add' requires one of these states: SELECTING_HAND, SHOP, ROUND_EVAL", ) def test_add_playing_card_from_SHOP(self, client: socket.socket) -> None: diff --git a/tests/lua/endpoints/test_buy.py b/tests/lua/endpoints/test_buy.py index 206a465..3ebf1ae 100644 --- a/tests/lua/endpoints/test_buy.py +++ b/tests/lua/endpoints/test_buy.py @@ -211,5 +211,5 @@ def test_buy_from_BLIND_SELECT(self, client: socket.socket) -> None: assert_error_response( api(client, "buy", {"card": 0}), "INVALID_STATE", - "Endpoint 'buy' requires one of these states: SHOP", + "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 25263f9..376d0c2 100644 --- a/tests/lua/endpoints/test_cash_out.py +++ b/tests/lua/endpoints/test_cash_out.py @@ -40,5 +40,5 @@ def test_cash_out_from_BLIND_SELECT(self, client: socket.socket): assert_error_response( api(client, "cash_out", {}), "INVALID_STATE", - "Endpoint 'cash_out' requires one of these states: ROUND_EVAL", + "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 index c22a10e..e653741 100644 --- a/tests/lua/endpoints/test_discard.py +++ b/tests/lua/endpoints/test_discard.py @@ -108,5 +108,5 @@ def test_discard_from_BLIND_SELECT(self, client: socket.socket): assert_error_response( api(client, "discard", {"cards": [0]}), "INVALID_STATE", - "Endpoint 'discard' requires one of these states: SELECTING_HAND", + "Method 'discard' requires one of these states: SELECTING_HAND", ) diff --git a/tests/lua/endpoints/test_next_round.py b/tests/lua/endpoints/test_next_round.py index 5543557..d06df19 100644 --- a/tests/lua/endpoints/test_next_round.py +++ b/tests/lua/endpoints/test_next_round.py @@ -39,6 +39,6 @@ def test_next_round_from_MENU(self, client: socket.socket): response = api(client, "next_round", {}) assert_error_response( response, - expected_error_code="INVALID_STATE", - expected_message_contains="Endpoint 'next_round' requires one of these states: SHOP", + "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 index a6927fd..7368c09 100644 --- a/tests/lua/endpoints/test_play.py +++ b/tests/lua/endpoints/test_play.py @@ -116,5 +116,5 @@ def test_play_from_BLIND_SELECT(self, client: socket.socket): assert_error_response( api(client, "play", {"cards": [0]}), "INVALID_STATE", - "Endpoint 'play' requires one of these states: SELECTING_HAND", + "Method 'play' requires one of these states: SELECTING_HAND", ) diff --git a/tests/lua/endpoints/test_rearrange.py b/tests/lua/endpoints/test_rearrange.py index 3a16898..fadf76e 100644 --- a/tests/lua/endpoints/test_rearrange.py +++ b/tests/lua/endpoints/test_rearrange.py @@ -192,7 +192,7 @@ def test_rearrange_hand_from_wrong_state(self, client: socket.socket) -> None: assert_error_response( api(client, "rearrange", {"hand": [0, 1, 2, 3, 4, 5, 6, 7]}), "INVALID_STATE", - "Endpoint 'rearrange' requires one of these states: SELECTING_HAND, SHOP", + "Method 'rearrange' requires one of these states: SELECTING_HAND, SHOP", ) def test_rearrange_jokers_from_wrong_state(self, client: socket.socket) -> None: @@ -202,7 +202,7 @@ def test_rearrange_jokers_from_wrong_state(self, client: socket.socket) -> None: assert_error_response( api(client, "rearrange", {"jokers": [0, 1, 2, 3, 4]}), "INVALID_STATE", - "Endpoint 'rearrange' requires one of these states: SELECTING_HAND, SHOP", + "Method 'rearrange' requires one of these states: SELECTING_HAND, SHOP", ) def test_rearrange_consumables_from_wrong_state( @@ -214,7 +214,7 @@ def test_rearrange_consumables_from_wrong_state( assert_error_response( api(client, "rearrange", {"jokers": [0, 1]}), "INVALID_STATE", - "Endpoint 'rearrange' requires one of these states: SELECTING_HAND, SHOP", + "Method 'rearrange' requires one of these states: SELECTING_HAND, SHOP", ) def test_rearrange_hand_from_shop(self, client: socket.socket) -> None: diff --git a/tests/lua/endpoints/test_reroll.py b/tests/lua/endpoints/test_reroll.py index 08a3785..59b9eab 100644 --- a/tests/lua/endpoints/test_reroll.py +++ b/tests/lua/endpoints/test_reroll.py @@ -39,5 +39,5 @@ def test_reroll_from_BLIND_SELECT(self, client: socket.socket): assert_error_response( api(client, "reroll", {}), "INVALID_STATE", - "Endpoint 'reroll' requires one of these states: SHOP", + "Method 'reroll' requires one of these states: SHOP", ) diff --git a/tests/lua/endpoints/test_save.py b/tests/lua/endpoints/test_save.py index 599882e..34d3036 100644 --- a/tests/lua/endpoints/test_save.py +++ b/tests/lua/endpoints/test_save.py @@ -48,8 +48,8 @@ def test_missing_path_parameter(self, client: socket.socket) -> None: response = api(client, "save", {}) assert_error_response( response, - expected_error_code="BAD_REQUEST", - expected_message_contains="Missing required field 'path'", + "BAD_REQUEST", + "Missing required field 'path'", ) def test_invalid_path_type(self, client: socket.socket) -> None: @@ -57,8 +57,8 @@ def test_invalid_path_type(self, client: socket.socket) -> None: response = api(client, "save", {"path": 123}) assert_error_response( response, - expected_error_code="BAD_REQUEST", - expected_message_contains="Field 'path' must be of type string", + "BAD_REQUEST", + "Field 'path' must be of type string", ) @@ -72,7 +72,7 @@ def test_save_from_MENU(self, client: socket.socket, tmp_path: Path) -> None: response = api(client, "save", {"path": str(temp_file)}) assert_error_response( response, - expected_error_code="INVALID_STATE", - expected_message_contains="Endpoint '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", + "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_select.py b/tests/lua/endpoints/test_select.py index 94385dd..f904508 100644 --- a/tests/lua/endpoints/test_select.py +++ b/tests/lua/endpoints/test_select.py @@ -65,5 +65,5 @@ def test_select_from_MENU(self, client: socket.socket): assert_error_response( api(client, "select", {}), "INVALID_STATE", - "Endpoint 'select' requires one of these states: BLIND_SELECT", + "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 index 7f0b324..73adb35 100644 --- a/tests/lua/endpoints/test_sell.py +++ b/tests/lua/endpoints/test_sell.py @@ -171,7 +171,7 @@ def test_sell_from_BLIND_SELECT(self, client: socket.socket) -> None: assert_error_response( api(client, "sell", {}), "INVALID_STATE", - "Endpoint 'sell' requires one of these states: SELECTING_HAND, SHOP", + "Method 'sell' requires one of these states: SELECTING_HAND, SHOP", ) def test_sell_from_ROUND_EVAL(self, client: socket.socket) -> None: @@ -181,5 +181,5 @@ def test_sell_from_ROUND_EVAL(self, client: socket.socket) -> None: assert_error_response( api(client, "sell", {}), "INVALID_STATE", - "Endpoint 'sell' requires one of these states: SELECTING_HAND, SHOP", + "Method 'sell' requires one of these states: SELECTING_HAND, SHOP", ) diff --git a/tests/lua/endpoints/test_set.py b/tests/lua/endpoints/test_set.py index 6d1501e..2da5ed2 100644 --- a/tests/lua/endpoints/test_set.py +++ b/tests/lua/endpoints/test_set.py @@ -14,8 +14,8 @@ def test_set_game_not_in_run(self, client: socket.socket) -> None: response = api(client, "set", {}) assert_error_response( response, - expected_error_code="INVALID_STATE", - expected_message_contains="Can only set during an active run", + "INVALID_STATE", + "Can only set during an active run", ) def test_set_no_fields(self, client: socket.socket) -> None: @@ -25,8 +25,8 @@ def test_set_no_fields(self, client: socket.socket) -> None: response = api(client, "set", {}) assert_error_response( response, - expected_error_code="BAD_REQUEST", - expected_message_contains="Must provide at least one field to set", + "BAD_REQUEST", + "Must provide at least one field to set", ) def test_set_negative_money(self, client: socket.socket) -> None: @@ -36,8 +36,8 @@ def test_set_negative_money(self, client: socket.socket) -> None: response = api(client, "set", {"money": -100}) assert_error_response( response, - expected_error_code="BAD_REQUEST", - expected_message_contains="Money must be a positive integer", + "BAD_REQUEST", + "Money must be a positive integer", ) def test_set_money(self, client: socket.socket) -> None: @@ -54,8 +54,8 @@ def test_set_negative_chips(self, client: socket.socket) -> None: response = api(client, "set", {"chips": -100}) assert_error_response( response, - expected_error_code="BAD_REQUEST", - expected_message_contains="Chips must be a positive integer", + "BAD_REQUEST", + "Chips must be a positive integer", ) def test_set_chips(self, client: socket.socket) -> None: @@ -72,8 +72,8 @@ def test_set_negative_ante(self, client: socket.socket) -> None: response = api(client, "set", {"ante": -8}) assert_error_response( response, - expected_error_code="BAD_REQUEST", - expected_message_contains="Ante must be a positive integer", + "BAD_REQUEST", + "Ante must be a positive integer", ) def test_set_ante(self, client: socket.socket) -> None: @@ -90,8 +90,8 @@ def test_set_negative_round(self, client: socket.socket) -> None: response = api(client, "set", {"round": -5}) assert_error_response( response, - expected_error_code="BAD_REQUEST", - expected_message_contains="Round must be a positive integer", + "BAD_REQUEST", + "Round must be a positive integer", ) def test_set_round(self, client: socket.socket) -> None: @@ -108,8 +108,8 @@ def test_set_negative_hands(self, client: socket.socket) -> None: response = api(client, "set", {"hands": -10}) assert_error_response( response, - expected_error_code="BAD_REQUEST", - expected_message_contains="Hands must be a positive integer", + "BAD_REQUEST", + "Hands must be a positive integer", ) def test_set_hands(self, client: socket.socket) -> None: @@ -126,8 +126,8 @@ def test_set_negative_discards(self, client: socket.socket) -> None: response = api(client, "set", {"discards": -10}) assert_error_response( response, - expected_error_code="BAD_REQUEST", - expected_message_contains="Discards must be a positive integer", + "BAD_REQUEST", + "Discards must be a positive integer", ) def test_set_discards(self, client: socket.socket) -> None: @@ -144,8 +144,8 @@ def test_set_shop_from_selecting_hand(self, client: socket.socket) -> None: response = api(client, "set", {"shop": True}) assert_error_response( response, - expected_error_code="NOT_ALLOWED", - expected_message_contains="Can re-stock shop only in SHOP state", + "NOT_ALLOWED", + "Can re-stock shop only in SHOP state", ) def test_set_shop_from_SHOP(self, client: socket.socket) -> None: @@ -183,8 +183,8 @@ def test_invalid_money_type(self, client: socket.socket): response = api(client, "set", {"money": "INVALID_STRING"}) assert_error_response( response, - expected_error_code="BAD_REQUEST", - expected_message_contains="Field 'money' must be an integer", + "BAD_REQUEST", + "Field 'money' must be an integer", ) def test_invalid_chips_type(self, client: socket.socket): @@ -194,8 +194,8 @@ def test_invalid_chips_type(self, client: socket.socket): response = api(client, "set", {"chips": "INVALID_STRING"}) assert_error_response( response, - expected_error_code="BAD_REQUEST", - expected_message_contains="Field 'chips' must be an integer", + "BAD_REQUEST", + "Field 'chips' must be an integer", ) def test_invalid_ante_type(self, client: socket.socket): @@ -205,8 +205,8 @@ def test_invalid_ante_type(self, client: socket.socket): response = api(client, "set", {"ante": "INVALID_STRING"}) assert_error_response( response, - expected_error_code="BAD_REQUEST", - expected_message_contains="Field 'ante' must be an integer", + "BAD_REQUEST", + "Field 'ante' must be an integer", ) def test_invalid_round_type(self, client: socket.socket): @@ -216,8 +216,8 @@ def test_invalid_round_type(self, client: socket.socket): response = api(client, "set", {"round": "INVALID_STRING"}) assert_error_response( response, - expected_error_code="BAD_REQUEST", - expected_message_contains="Field 'round' must be an integer", + "BAD_REQUEST", + "Field 'round' must be an integer", ) def test_invalid_hands_type(self, client: socket.socket): @@ -227,8 +227,8 @@ def test_invalid_hands_type(self, client: socket.socket): response = api(client, "set", {"hands": "INVALID_STRING"}) assert_error_response( response, - expected_error_code="BAD_REQUEST", - expected_message_contains="Field 'hands' must be an integer", + "BAD_REQUEST", + "Field 'hands' must be an integer", ) def test_invalid_discards_type(self, client: socket.socket): @@ -238,8 +238,8 @@ def test_invalid_discards_type(self, client: socket.socket): response = api(client, "set", {"discards": "INVALID_STRING"}) assert_error_response( response, - expected_error_code="BAD_REQUEST", - expected_message_contains="Field 'discards' must be an integer", + "BAD_REQUEST", + "Field 'discards' must be an integer", ) def test_invalid_shop_type(self, client: socket.socket): @@ -249,6 +249,6 @@ def test_invalid_shop_type(self, client: socket.socket): response = api(client, "set", {"shop": "INVALID_STRING"}) assert_error_response( response, - expected_error_code="BAD_REQUEST", - expected_message_contains="Field 'shop' must be of type boolean", + "BAD_REQUEST", + "Field 'shop' must be of type boolean", ) diff --git a/tests/lua/endpoints/test_skip.py b/tests/lua/endpoints/test_skip.py index 48da75b..d605670 100644 --- a/tests/lua/endpoints/test_skip.py +++ b/tests/lua/endpoints/test_skip.py @@ -69,5 +69,5 @@ def test_skip_from_MENU(self, client: socket.socket): assert_error_response( api(client, "skip", {}), "INVALID_STATE", - "Endpoint 'skip' requires one of these states: BLIND_SELECT", + "Method 'skip' requires one of these states: BLIND_SELECT", ) diff --git a/tests/lua/endpoints/test_start.py b/tests/lua/endpoints/test_start.py index ff8af94..5f97dcc 100644 --- a/tests/lua/endpoints/test_start.py +++ b/tests/lua/endpoints/test_start.py @@ -85,8 +85,8 @@ def test_missing_deck_parameter(self, client: socket.socket): response = api(client, "start", {"stake": "WHITE"}) assert_error_response( response, - expected_error_code="BAD_REQUEST", - expected_message_contains="Missing required field 'deck'", + "BAD_REQUEST", + "Missing required field 'deck'", ) def test_missing_stake_parameter(self, client: socket.socket): @@ -96,8 +96,8 @@ def test_missing_stake_parameter(self, client: socket.socket): response = api(client, "start", {"deck": "RED"}) assert_error_response( response, - expected_error_code="BAD_REQUEST", - expected_message_contains="Missing required field 'stake'", + "BAD_REQUEST", + "Missing required field 'stake'", ) def test_invalid_deck_value(self, client: socket.socket): @@ -107,8 +107,8 @@ def test_invalid_deck_value(self, client: socket.socket): response = api(client, "start", {"deck": "INVALID_DECK", "stake": "WHITE"}) assert_error_response( response, - expected_error_code="BAD_REQUEST", - expected_message_contains="Invalid deck enum. Must be one of:", + "BAD_REQUEST", + "Invalid deck enum. Must be one of:", ) def test_invalid_stake_value(self, client: socket.socket): @@ -118,8 +118,8 @@ def test_invalid_stake_value(self, client: socket.socket): response = api(client, "start", {"deck": "RED", "stake": "INVALID_STAKE"}) assert_error_response( response, - expected_error_code="BAD_REQUEST", - expected_message_contains="Invalid stake enum. Must be one of:", + "BAD_REQUEST", + "Invalid stake enum. Must be one of:", ) def test_invalid_deck_type(self, client: socket.socket): @@ -129,8 +129,8 @@ def test_invalid_deck_type(self, client: socket.socket): response = api(client, "start", {"deck": 123, "stake": "WHITE"}) assert_error_response( response, - expected_error_code="BAD_REQUEST", - expected_message_contains="Field 'deck' must be of type string", + "BAD_REQUEST", + "Field 'deck' must be of type string", ) def test_invalid_stake_type(self, client: socket.socket): @@ -140,8 +140,8 @@ def test_invalid_stake_type(self, client: socket.socket): response = api(client, "start", {"deck": "RED", "stake": 1}) assert_error_response( response, - expected_error_code="BAD_REQUEST", - expected_message_contains="Field 'stake' must be of type string", + "BAD_REQUEST", + "Field 'stake' must be of type string", ) @@ -155,6 +155,6 @@ def test_start_from_BLIND_SELECT(self, client: socket.socket): response = api(client, "start", {"deck": "RED", "stake": "WHITE"}) assert_error_response( response, - expected_error_code="INVALID_STATE", - expected_message_contains="Endpoint 'start' requires one of these states: MENU", + "INVALID_STATE", + "Method 'start' requires one of these states: MENU", ) diff --git a/tests/lua/endpoints/test_use.py b/tests/lua/endpoints/test_use.py index 81b853d..7ca6d58 100644 --- a/tests/lua/endpoints/test_use.py +++ b/tests/lua/endpoints/test_use.py @@ -302,7 +302,7 @@ def test_use_from_BLIND_SELECT(self, client: socket.socket) -> None: assert_error_response( api(client, "use", {"consumable": 0, "cards": [0]}), "INVALID_STATE", - "Endpoint 'use' requires one of these states: SELECTING_HAND, SHOP", + "Method 'use' requires one of these states: SELECTING_HAND, SHOP", ) def test_use_from_ROUND_EVAL(self, client: socket.socket) -> None: @@ -316,7 +316,7 @@ def test_use_from_ROUND_EVAL(self, client: socket.socket) -> None: assert_error_response( api(client, "use", {"consumable": 0, "cards": [0]}), "INVALID_STATE", - "Endpoint 'use' requires one of these states: SELECTING_HAND, SHOP", + "Method 'use' requires one of these states: SELECTING_HAND, SHOP", ) def test_use_magician_from_SHOP(self, client: socket.socket) -> None: From 43dc6ca6e1ba0021b63feef6875e9eff2a473005 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Fri, 5 Dec 2025 17:23:16 +0100 Subject: [PATCH 178/230] style(lua): make the code sytle consistent in the whole project --- src/lua/core/dispatcher.lua | 164 +++++------------------- src/lua/core/server.lua | 168 ++++++++++--------------- src/lua/core/validator.lua | 100 ++++----------- src/lua/endpoints/add.lua | 83 ++++++------ src/lua/endpoints/buy.lua | 51 ++++---- src/lua/endpoints/cash_out.lua | 18 ++- src/lua/endpoints/discard.lua | 37 ++++-- src/lua/endpoints/gamestate.lua | 26 ++-- src/lua/endpoints/health.lua | 26 ++-- src/lua/endpoints/load.lua | 40 +++--- src/lua/endpoints/menu.lua | 16 ++- src/lua/endpoints/next_round.lua | 18 ++- src/lua/endpoints/play.lua | 32 +++-- src/lua/endpoints/rearrange.lua | 58 +++++---- src/lua/endpoints/reroll.lua | 20 ++- src/lua/endpoints/save.lua | 64 +++++----- src/lua/endpoints/select.lua | 23 +++- src/lua/endpoints/sell.lua | 43 ++++--- src/lua/endpoints/set.lua | 54 ++++---- src/lua/endpoints/skip.lua | 23 +++- src/lua/endpoints/start.lua | 41 +++--- src/lua/endpoints/tests/echo.lua | 29 +++-- src/lua/endpoints/tests/endpoint.lua | 31 ++--- src/lua/endpoints/tests/error.lua | 18 ++- src/lua/endpoints/tests/state.lua | 24 ++-- src/lua/endpoints/tests/validation.lua | 35 +++--- src/lua/endpoints/use.lua | 58 +++++---- src/lua/settings.lua | 11 +- src/lua/utils/debugger.lua | 3 +- src/lua/utils/errors.lua | 22 +--- src/lua/utils/types.lua | 110 ++++++++++++++-- 31 files changed, 764 insertions(+), 682 deletions(-) diff --git a/src/lua/core/dispatcher.lua b/src/lua/core/dispatcher.lua index 32f9a2d..016321e 100644 --- a/src/lua/core/dispatcher.lua +++ b/src/lua/core/dispatcher.lua @@ -1,34 +1,18 @@ --- src/lua/core/dispatcher.lua --- Request Dispatcher with 4-Tier Validation --- --- Routes API calls to endpoints with comprehensive validation: --- 1. Protocol validation (has name, arguments) --- 2. Schema validation (via Validator) --- 3. Game state validation (requires_state check) --- 4. Execute endpoint (catch semantic errors and enrich) --- --- Responsibilities: --- - Auto-discover and register all endpoints at startup (fail-fast) --- - Validate request structure at protocol level --- - Route requests to appropriate endpoints --- - Delegate schema validation to Validator module --- - Enforce game state requirements --- - Execute endpoints with comprehensive error handling --- - Send responses and rich error messages via Server module +--[[ + 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"))() --- State name lookup cache (built lazily from G.STATES) ----@type table? +---@type table? local STATE_NAME_CACHE = nil ---- Get the name of a state from its numeric value ---- Builds a reverse mapping from G.STATES on first call ----@param state_value number The numeric state value ----@return string state_name The state name (or stringified number if not found) +---@param state_value integer +---@return string local function get_state_name(state_value) - -- Build cache on first use if not STATE_NAME_CACHE then STATE_NAME_CACHE = {} if G and G.STATES then @@ -37,53 +21,33 @@ local function get_state_name(state_value) end end end - - -- Look up the name, fall back to stringified number return STATE_NAME_CACHE[state_value] or tostring(state_value) end ----@class Dispatcher ----@field endpoints table Endpoint registry mapping names to modules ----@field Server table? Reference to Server module for sending responses BB_DISPATCHER = { - - -- Endpoint registry: name -> endpoint module - ---@type table endpoints = {}, - - -- Reference to Server module (set after initialization) - ---@type table? Server = nil, } ---- Validate that an endpoint module has the required structure ----@param endpoint Endpoint The endpoint module to validate +---@param endpoint Endpoint ---@return boolean success ---@return string? error_message local function validate_endpoint_structure(endpoint) - -- Check required fields 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 - - -- requires_state is optional but must be nil or table if present 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 - - -- Validate schema structure (basic check) 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" @@ -92,178 +56,122 @@ local function validate_endpoint_structure(endpoint) return false, "Endpoint '" .. endpoint.name .. "' schema field '" .. field_name .. "' missing 'type' definition" end end - return true end ---- Register a single endpoint ---- Validates the endpoint structure and adds it to the registry ----@param endpoint Endpoint The endpoint module to register +---@param endpoint Endpoint ---@return boolean success ---@return string? error_message function BB_DISPATCHER.register(endpoint) - -- Validate endpoint structure local valid, err = validate_endpoint_structure(endpoint) if not valid then return false, err end - - -- Check for duplicate names if BB_DISPATCHER.endpoints[endpoint.name] then return false, "Endpoint '" .. endpoint.name .. "' is already registered" end - - -- Register endpoint BB_DISPATCHER.endpoints[endpoint.name] = endpoint sendDebugMessage("Registered endpoint: " .. endpoint.name, "BB.DISPATCHER") - return true end ---- Load all endpoint modules from a directory ---- Loads .lua files and registers each endpoint (fail-fast) ----@param endpoint_files string[] List of endpoint file paths relative to mod root +---@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") - - -- Load endpoint module (fail-fast on errors) 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 - - -- Register endpoint (fail-fast on validation errors) 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 ---- Initialize the dispatcher ---- Loads all endpoints from the provided list ----@param server_module table Reference to Server module for sending responses ----@param endpoint_files string[]? Optional list of endpoint file paths (default: health and gamestate) +---@param server_module table +---@param endpoint_files string[]? ---@return boolean success function BB_DISPATCHER.init(server_module, endpoint_files) BB_DISPATCHER.Server = server_module - - -- Default endpoint files if none provided - endpoint_files = endpoint_files or { - "src/lua/endpoints/health.lua", - } - - -- Load all endpoints (fail-fast) + 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 ---- Send an error response via Server module ----@param message string Error message ----@param error_code string Error code +---@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_error(message, error_code) + BB_DISPATCHER.Server.send_response({ + message = message, + name = error_code, + }) end ---- Dispatch a request to the appropriate endpoint ---- Performs 4-tier validation and executes the endpoint ----@param request table The parsed JSON request +---@param request Request function BB_DISPATCHER.dispatch(request) - -- ================================================================= - -- TIER 1: Protocol Validation - -- ================================================================= - - -- Validate request has 'name' field - if not request.name or type(request.name) ~= "string" then - BB_DISPATCHER.send_error("Request missing 'name' field", BB_ERROR_NAMES.BAD_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 - - -- Validate request has 'arguments' field - if not request.arguments then - BB_DISPATCHER.send_error("Request missing 'arguments' field", BB_ERROR_NAMES.BAD_REQUEST) - return - end - - -- Find endpoint - local endpoint = BB_DISPATCHER.endpoints[request.name] + local params = request.params or {} + local endpoint = BB_DISPATCHER.endpoints[request.method] if not endpoint then - BB_DISPATCHER.send_error("Unknown endpoint: " .. request.name, BB_ERROR_NAMES.BAD_REQUEST) + BB_DISPATCHER.send_error("Unknown method: " .. request.method, BB_ERROR_NAMES.BAD_REQUEST) return end + sendDebugMessage("Dispatching: " .. request.method, "BB.DISPATCHER") - sendDebugMessage("Dispatching: " .. request.name, "BB.DISPATCHER") - - -- ================================================================= -- TIER 2: Schema Validation - -- ================================================================= - - local valid, err_msg, err_code = Validator.validate(request.arguments, endpoint.schema) + local valid, err_msg, err_code = Validator.validate(params, endpoint.schema) if not valid then - -- When validation fails, err_msg and err_code are guaranteed to be non-nil - BB_DISPATCHER.send_error(err_msg or "Validation failed", err_code or "VALIDATION_ERROR") + 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 - -- Convert state numbers to names for the error message local state_names = {} for _, state in ipairs(endpoint.requires_state) do table.insert(state_names, get_state_name(state)) end - BB_DISPATCHER.send_error( - "Endpoint '" .. request.name .. "' requires one of these states: " .. table.concat(state_names, ", "), + "Method '" .. request.method .. "' requires one of these states: " .. table.concat(state_names, ", "), BB_ERROR_NAMES.INVALID_STATE ) return end end - -- ================================================================= -- TIER 4: Execute Endpoint - -- ================================================================= - - -- Create send_response callback that uses Server.send_response local function send_response(response) if BB_DISPATCHER.Server then BB_DISPATCHER.Server.send_response(response) @@ -271,16 +179,10 @@ function BB_DISPATCHER.dispatch(request) sendDebugMessage("Cannot send response - Server not initialized", "BB.DISPATCHER") end end - - -- Execute endpoint with error handling local exec_success, exec_error = pcall(function() - endpoint.execute(request.arguments, send_response) + endpoint.execute(params, send_response) end) - if not exec_success then - -- Endpoint threw an error - local error_message = tostring(exec_error) - - BB_DISPATCHER.send_error(error_message, BB_ERROR_NAMES.INTERNAL_ERROR) + 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 index 84f3c7d..6cbe740 100644 --- a/src/lua/core/server.lua +++ b/src/lua/core/server.lua @@ -1,222 +1,188 @@ --- src/lua/core/server.lua --- TCP Server for BalatroBot API --- --- Simplified single-client server (assumes only one client connects) --- --- Responsibilities: --- - Create and bind TCP socket (non-blocking) on port 12346 --- - Accept client connections (overwrites previous client) --- - Receive JSON-only requests (newline-delimited) --- - Pass requests to Dispatcher --- - Send responses back to client +--[[ + TCP Server - Single-client, non-blocking server on port 12346. + JSON-RPC 2.0 protocol with newline-delimited messages. +]] local socket = require("socket") local json = require("json") BB_SERVER = { - - -- Configuration - ---@type string host = BB_SETTINGS.host, - ---@type integer port = BB_SETTINGS.port, - - -- Sockets - ---@type TCPSocketServer? server_socket = nil, - ---@type TCPSocketClient? client_socket = nil, + current_request_id = nil, } ---- Initialize the TCP server ---- Creates and binds a non-blocking TCP socket on the configured port ---- @return boolean success +---@return boolean success function BB_SERVER.init() - -- Create TCP socket local server, err = socket.tcp() if not server then sendErrorMessage("Failed to create socket: " .. tostring(err), "BB.SERVER") return false end - - -- Bind to port 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 - - -- Start listening (backlog of 1 for single client model) 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 - - -- Set non-blocking mode server:settimeout(0) - BB_SERVER.server_socket = server - sendDebugMessage("Listening on " .. BB_SERVER.host .. ":" .. BB_SERVER.port, "BB.SERVER") return true end ---- Accept a new client connection ---- Simply accepts any incoming connection (overwrites previous client if any) ---- @return boolean accepted +---@return boolean accepted function BB_SERVER.accept() if not BB_SERVER.server_socket then return false end - - -- Accept new client (will overwrite any existing client) local client, err = BB_SERVER.server_socket:accept() if err then if err ~= "timeout" then sendErrorMessage("Failed to accept client: " .. tostring(err), "BB.SERVER") - return false end return false end - if client and not err then - -- Close previous client socket if exists (single-client model) + if client then if BB_SERVER.client_socket then BB_SERVER.client_socket:close() BB_SERVER.client_socket = nil end - - client:settimeout(0) -- Non-blocking + client:settimeout(0) BB_SERVER.client_socket = client sendDebugMessage("Client connected", "BB.SERVER") return true end - return false end ---- Receive and parse a single JSON request from client ---- Ultra-simple protocol: JSON + '\n' (nothing else allowed) ---- Max payload: 256 bytes ---- Non-blocking: returns empty array if no complete request available ---- @return table[] requests Array with at most one parsed JSON request object +--- Max payload: 256 bytes. Non-blocking, returns empty array if no data. +---@return Request[] function BB_SERVER.receive() if not BB_SERVER.client_socket then return {} end - - -- Read one line (non-blocking) BB_SERVER.client_socket:settimeout(0) local line, err = BB_SERVER.client_socket:receive("*l") - if not line then - -- If connection closed, clean up the socket reference if err == "closed" then BB_SERVER.client_socket:close() BB_SERVER.client_socket = nil end - return {} -- No data available or connection closed + return {} end - - -- Check message size (line doesn't include the \n, so +1 for newline) if #line + 1 > 256 then - BB_SERVER.send_error("Request too large: maximum 256 bytes including newline", "BAD_REQUEST") + BB_SERVER.current_request_id = nil + BB_SERVER.send_response({ + message = "Request too large: maximum 256 bytes including newline", + name = BB_ERROR_NAMES.BAD_REQUEST, + }) return {} end - - -- Ignore empty lines if line == "" then return {} end - - -- Check that JSON starts with '{' (must be object, not array/primitive) local trimmed = line:match("^%s*(.-)%s*$") if not trimmed:match("^{") then - BB_SERVER.send_error("Invalid JSON in request: must be object (start with '{')", "BAD_REQUEST") + BB_SERVER.current_request_id = nil + BB_SERVER.send_response({ + message = "Invalid JSON in request: must be object (start with '{')", + name = BB_ERROR_NAMES.BAD_REQUEST, + }) return {} end - - -- Parse JSON local success, parsed = pcall(json.decode, line) - if success and type(parsed) == "table" then - return { parsed } - else - BB_SERVER.send_error("Invalid JSON in request", "BAD_REQUEST") + if not success or type(parsed) ~= "table" then + BB_SERVER.current_request_id = nil + BB_SERVER.send_response({ + message = "Invalid JSON in request", + name = BB_ERROR_NAMES.BAD_REQUEST, + }) + return {} + end + 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 + BB_SERVER.current_request_id = parsed.id + return { parsed } end ---- Send a response to the client --- @param response table Response object to encode as JSON --- @return boolean success +---@param response EndpointResponse +---@return boolean success function BB_SERVER.send_response(response) if not BB_SERVER.client_socket then return false end - - -- Encode to JSON - local success, json_str = pcall(json.encode, response) + local wrapped + if response.message then + 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 + 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 with newline delimiter - local data = json_str .. "\n" - local _, err = BB_SERVER.client_socket:send(data) - + local _, err = BB_SERVER.client_socket:send(json_str .. "\n") if err then sendDebugMessage("Failed to send response: " .. err, "BB.SERVER") return false end - return true end ---- Send an error response to the client --- @param message string Error message --- @param error_code string Error code (e.g., "PROTO_INVALID_JSON") -function BB_SERVER.send_error(message, error_code) - BB_SERVER.send_response({ - error = message, - error_code = error_code, - }) -end - ---- Update loop - called from game's update cycle --- Handles accepting connections, receiving requests, and dispatching --- @param dispatcher table? Dispatcher module for routing requests (optional for now) +---@param dispatcher Dispatcher? function BB_SERVER.update(dispatcher) if not BB_SERVER.server_socket then return end - - -- Accept new connections (single client only) BB_SERVER.accept() - - -- Receive and process requests if BB_SERVER.client_socket then local requests = BB_SERVER.receive() - for _, request in ipairs(requests) do if dispatcher and dispatcher.dispatch then - -- Pass to Dispatcher when available dispatcher.dispatch(request, BB_SERVER.client_socket) else - -- Placeholder: send error that dispatcher not ready - BB_SERVER.send_error("Server not fully initialized (dispatcher not ready)", "INVALID_STATE") + BB_SERVER.send_response({ + message = "Server not fully initialized (dispatcher not ready)", + name = BB_ERROR_NAMES.INVALID_STATE, + }) end end end end ---- Cleanup and close server function BB_SERVER.close() if BB_SERVER.client_socket then BB_SERVER.client_socket:close() BB_SERVER.client_socket = nil end - if BB_SERVER.server_socket then BB_SERVER.server_socket:close() BB_SERVER.server_socket = nil diff --git a/src/lua/core/validator.lua b/src/lua/core/validator.lua index 3ee3b6c..19a4223 100644 --- a/src/lua/core/validator.lua +++ b/src/lua/core/validator.lua @@ -1,55 +1,25 @@ --- src/lua/core/validator.lua --- Schema Validator for Endpoint Arguments --- --- Validates endpoint arguments against schema definitions using fail-fast validation. --- Stops at the first error encountered and returns detailed error information. --- --- Validation Approach: --- - No automatic defaults (endpoints handle optional arguments explicitly) --- - Fail-fast (returns first validation error encountered) --- - Type-strict (enforces exact type matches, no implicit conversions) --- - Minimal schema (only type, required, items, description fields) --- --- Supported Types: --- - string: Basic string type --- - integer: Integer number (validated with math.floor check) --- - boolean: Boolean type (true/false) --- - array: Array of items (validated with sequential numeric indices) --- - table: Generic table type (non-array tables) --- --- Range/Length Validation: --- Min/max validation is NOT handled by the validator. Endpoints implement --- their own dynamic validation based on game state (e.g., valid card indices, --- valid stake ranges, etc.) +--[[ + Schema Validator - Fail-fast validation for endpoint arguments. + Types: string, integer, boolean, array, table. + No defaults or range validation (endpoints handle these). +]] ----@type ErrorCodes -local errors = assert(SMODS.load_file("src/lua/utils/errors.lua"))() - ----@class SchemaField ----@field type "string"|"integer"|"array"|"boolean"|"table" The field type ----@field required boolean? Whether the field is required ----@field items "integer"? Type of array items (only "integer" supported, only for array type) ----@field description string Description of the field (required) - ----@class Validator local Validator = {} ---- Check if a value is an integer ----@param value any Value to check ----@return boolean is_integer +---@param value any +---@return boolean local function is_integer(value) return type(value) == "number" and math.floor(value) == value end ---- Check if a value is an array (table with sequential numeric indices) ----@param value any Value to check ----@return boolean is_array +---@param value any +---@return boolean local function is_array(value) if type(value) ~= "table" then return false end local count = 0 - for k, _v in pairs(value) do + for k, _ in pairs(value) do count = count + 1 if type(k) ~= "number" or k ~= count then return false @@ -58,82 +28,59 @@ local function is_array(value) return true end ---- Validate a single field against its schema definition ----@param field_name string Name of the field being validated ----@param value any The value to validate ----@param field_schema SchemaField The schema definition for this field +---@param field_name string +---@param value any +---@param field_schema SchemaField ---@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 - - -- Check type if expected_type == "integer" then if not is_integer(value) then - return false, "Field '" .. field_name .. "' must be an integer", errors.BAD_REQUEST + 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", errors.BAD_REQUEST + return false, "Field '" .. field_name .. "' must be an array", BB_ERROR_NAMES.BAD_REQUEST end elseif expected_type == "table" then - -- Empty tables are allowed, non-empty arrays are rejected if type(value) ~= "table" or (next(value) ~= nil and is_array(value)) then - return false, "Field '" .. field_name .. "' must be a table", errors.BAD_REQUEST + return false, "Field '" .. field_name .. "' must be a table", BB_ERROR_NAMES.BAD_REQUEST end else - -- Standard Lua types: string, boolean if type(value) ~= expected_type then - return false, "Field '" .. field_name .. "' must be of type " .. expected_type, errors.BAD_REQUEST + return false, "Field '" .. field_name .. "' must be of type " .. expected_type, BB_ERROR_NAMES.BAD_REQUEST end end - - -- Validate array item types if specified (only for array type) 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 = false - - if item_type == "integer" then - item_valid = is_integer(item) - else - item_valid = type(item) == item_type - end - + 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, - errors.BAD_REQUEST + BB_ERROR_NAMES.BAD_REQUEST end end end - return true end ---- Validate arguments against a schema definition ----@param args table The arguments to validate ----@param schema table The schema definition +---@param args table +---@param schema table ---@return boolean success ---@return string? error_message ---@return string? error_code function Validator.validate(args, schema) - -- Ensure args is a table if type(args) ~= "table" then - return false, "Arguments must be a table", errors.BAD_REQUEST + return false, "Arguments must be a table", BB_ERROR_NAMES.BAD_REQUEST end - - -- Validate each field in the schema for field_name, field_schema in pairs(schema) do local value = args[field_name] - - -- Check required fields if field_schema.required and value == nil then - return false, "Missing required field '" .. field_name .. "'", errors.BAD_REQUEST + return false, "Missing required field '" .. field_name .. "'", BB_ERROR_NAMES.BAD_REQUEST end - - -- Validate field if present (skip optional fields that are nil) if value ~= nil then local success, err_msg, err_code = validate_field(field_name, value, field_schema) if not success then @@ -141,7 +88,6 @@ function Validator.validate(args, schema) end end end - return true end diff --git a/src/lua/endpoints/add.lua b/src/lua/endpoints/add.lua index e0ec347..2f827c2 100644 --- a/src/lua/endpoints/add.lua +++ b/src/lua/endpoints/add.lua @@ -1,7 +1,8 @@ -- src/lua/endpoints/add.lua --- Add Endpoint --- --- Add a new card to the game using SMODS.add_card + +-- ========================================================================== +-- Add Endpoint Params +-- ========================================================================== ---@class 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) @@ -12,6 +13,10 @@ ---@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", @@ -105,10 +110,17 @@ local function parse_playing_card_key(key) 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", @@ -146,10 +158,11 @@ return { 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 Endpoint.Add.Params The arguments for the endpoint - ---@param send_response fun(response: table) Callback to send response + ---@param args Endpoint.Add.Params + ---@param send_response fun(response: EndpointResponse) execute = function(args, send_response) sendDebugMessage("Init add()", "BB.ENDPOINTS") @@ -158,8 +171,8 @@ return { if not card_type then send_response({ - error = "Invalid card key format. Expected: joker (j_*), consumable (c_*), voucher (v_*), or playing card (SUIT_RANK)", - error_code = BB_ERROR_NAMES.BAD_REQUEST, + 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 @@ -167,8 +180,8 @@ return { -- 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({ - error = "Playing cards can only be added in SELECTING_HAND state", - error_code = BB_ERROR_NAMES.INVALID_STATE, + message = "Playing cards can only be added in SELECTING_HAND state", + name = BB_ERROR_NAMES.INVALID_STATE, }) return end @@ -176,8 +189,8 @@ return { -- Special validation for vouchers - can only be added in SHOP state if card_type == "voucher" and G.STATE ~= G.STATES.SHOP then send_response({ - error = "Vouchers can only be added in SHOP state", - error_code = BB_ERROR_NAMES.INVALID_STATE, + message = "Vouchers can only be added in SHOP state", + name = BB_ERROR_NAMES.INVALID_STATE, }) return end @@ -185,8 +198,8 @@ return { -- Validate seal parameter is only for playing cards if args.seal and card_type ~= "playing_card" then send_response({ - error = "Seal can only be applied to playing cards", - error_code = BB_ERROR_NAMES.BAD_REQUEST, + message = "Seal can only be applied to playing cards", + name = BB_ERROR_NAMES.BAD_REQUEST, }) return end @@ -197,8 +210,8 @@ return { seal_value = SEAL_MAP[args.seal] if not seal_value then send_response({ - error = "Invalid seal value. Expected: RED, BLUE, GOLD, or PURPLE", - error_code = BB_ERROR_NAMES.BAD_REQUEST, + message = "Invalid seal value. Expected: RED, BLUE, GOLD, or PURPLE", + name = BB_ERROR_NAMES.BAD_REQUEST, }) return end @@ -207,8 +220,8 @@ return { -- Validate edition parameter is only for jokers, playing cards, or consumables if args.edition and card_type == "voucher" then send_response({ - error = "Edition cannot be applied to vouchers", - error_code = BB_ERROR_NAMES.BAD_REQUEST, + message = "Edition cannot be applied to vouchers", + name = BB_ERROR_NAMES.BAD_REQUEST, }) return end @@ -216,8 +229,8 @@ return { -- Special validation: consumables can only have NEGATIVE edition if args.edition and card_type == "consumable" and args.edition ~= "NEGATIVE" then send_response({ - error = "Consumables can only have NEGATIVE edition", - error_code = BB_ERROR_NAMES.BAD_REQUEST, + message = "Consumables can only have NEGATIVE edition", + name = BB_ERROR_NAMES.BAD_REQUEST, }) return end @@ -228,8 +241,8 @@ return { edition_value = EDITION_MAP[args.edition] if not edition_value then send_response({ - error = "Invalid edition value. Expected: HOLO, FOIL, POLYCHROME, or NEGATIVE", - error_code = BB_ERROR_NAMES.BAD_REQUEST, + message = "Invalid edition value. Expected: HOLO, FOIL, POLYCHROME, or NEGATIVE", + name = BB_ERROR_NAMES.BAD_REQUEST, }) return end @@ -238,8 +251,8 @@ return { -- Validate enhancement parameter is only for playing cards if args.enhancement and card_type ~= "playing_card" then send_response({ - error = "Enhancement can only be applied to playing cards", - error_code = BB_ERROR_NAMES.BAD_REQUEST, + message = "Enhancement can only be applied to playing cards", + name = BB_ERROR_NAMES.BAD_REQUEST, }) return end @@ -250,8 +263,8 @@ return { enhancement_value = ENHANCEMENT_MAP[args.enhancement] if not enhancement_value then send_response({ - error = "Invalid enhancement value. Expected: BONUS, MULT, WILD, GLASS, STEEL, STONE, GOLD, or LUCKY", - error_code = BB_ERROR_NAMES.BAD_REQUEST, + message = "Invalid enhancement value. Expected: BONUS, MULT, WILD, GLASS, STEEL, STONE, GOLD, or LUCKY", + name = BB_ERROR_NAMES.BAD_REQUEST, }) return end @@ -260,8 +273,8 @@ return { -- Validate eternal parameter is only for jokers if args.eternal and card_type ~= "joker" then send_response({ - error = "Eternal can only be applied to jokers", - error_code = BB_ERROR_NAMES.BAD_REQUEST, + message = "Eternal can only be applied to jokers", + name = BB_ERROR_NAMES.BAD_REQUEST, }) return end @@ -269,8 +282,8 @@ return { -- Validate perishable parameter is only for jokers if args.perishable and card_type ~= "joker" then send_response({ - error = "Perishable can only be applied to jokers", - error_code = BB_ERROR_NAMES.BAD_REQUEST, + message = "Perishable can only be applied to jokers", + name = BB_ERROR_NAMES.BAD_REQUEST, }) return end @@ -279,8 +292,8 @@ return { if args.perishable then if type(args.perishable) ~= "number" or args.perishable ~= math.floor(args.perishable) or args.perishable < 1 then send_response({ - error = "Perishable must be a positive integer (>= 1)", - error_code = BB_ERROR_NAMES.BAD_REQUEST, + message = "Perishable must be a positive integer (>= 1)", + name = BB_ERROR_NAMES.BAD_REQUEST, }) return end @@ -289,8 +302,8 @@ return { -- Validate rental parameter is only for jokers if args.rental and card_type ~= "joker" then send_response({ - error = "Rental can only be applied to jokers", - error_code = BB_ERROR_NAMES.BAD_REQUEST, + message = "Rental can only be applied to jokers", + name = BB_ERROR_NAMES.BAD_REQUEST, }) return end @@ -370,8 +383,8 @@ return { if not success then send_response({ - error = "Failed to add card: " .. args.key, - error_code = BB_ERROR_NAMES.BAD_REQUEST, + message = "Failed to add card: " .. args.key, + name = BB_ERROR_NAMES.BAD_REQUEST, }) return end diff --git a/src/lua/endpoints/buy.lua b/src/lua/endpoints/buy.lua index 3f8fc9d..4f48045 100644 --- a/src/lua/endpoints/buy.lua +++ b/src/lua/endpoints/buy.lua @@ -1,17 +1,25 @@ -- src/lua/endpoints/buy.lua --- Buy Endpoint --- --- Buy a card from the shop + +-- ========================================================================== +-- Buy Endpoint Params +-- ========================================================================== ---@class 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", @@ -29,10 +37,11 @@ return { description = "0-based index of pack to buy", }, }, + requires_state = { G.STATES.SHOP }, - ---@param args Endpoint.Buy.Params The arguments for the endpoint - ---@param send_response fun(response: table) Callback to send response + ---@param args Endpoint.Buy.Params + ---@param send_response fun(response: EndpointResponse) execute = function(args, send_response) sendDebugMessage("Init buy()", "BB.ENDPOINTS") local gamestate = BB_GAMESTATE.get_gamestate() @@ -63,8 +72,8 @@ return { -- Validate that only one of card, voucher, or pack is provided if not area then send_response({ - error = "Invalid arguments. You must provide one of: card, voucher, pack", - error_code = BB_ERROR_NAMES.BAD_REQUEST, + message = "Invalid arguments. You must provide one of: card, voucher, pack", + name = BB_ERROR_NAMES.BAD_REQUEST, }) return end @@ -72,8 +81,8 @@ return { -- Validate that only one of card, voucher, or pack is provided if set > 1 then send_response({ - error = "Invalid arguments. Cannot provide more than one of: card, voucher, or pack", - error_code = BB_ERROR_NAMES.BAD_REQUEST, + message = "Invalid arguments. Cannot provide more than one of: card, voucher, or pack", + name = BB_ERROR_NAMES.BAD_REQUEST, }) return end @@ -89,8 +98,8 @@ return { msg = "No boosters/standard/buffoon packs to open" end send_response({ - error = msg, - error_code = BB_ERROR_NAMES.BAD_REQUEST, + message = msg, + name = BB_ERROR_NAMES.BAD_REQUEST, }) return end @@ -98,8 +107,8 @@ return { -- Validate card index is in range if not area.cards[pos] then send_response({ - error = "Card index out of range. Index: " .. args.card .. ", Available cards: " .. area.count, - error_code = BB_ERROR_NAMES.BAD_REQUEST, + message = "Card index out of range. Index: " .. args.card .. ", Available cards: " .. area.count, + name = BB_ERROR_NAMES.BAD_REQUEST, }) return end @@ -110,8 +119,8 @@ return { -- Check if the card can be afforded if card.cost.buy > G.GAME.dollars then send_response({ - error = "Card is not affordable. Cost: " .. card.cost.buy .. ", Current money: " .. gamestate.money, - error_code = BB_ERROR_NAMES.BAD_REQUEST, + message = "Card is not affordable. Cost: " .. card.cost.buy .. ", Current money: " .. gamestate.money, + name = BB_ERROR_NAMES.BAD_REQUEST, }) return end @@ -120,11 +129,11 @@ return { if card.set == "JOKER" then if gamestate.jokers.count >= gamestate.jokers.limit then send_response({ - error = "Cannot purchase joker card, joker slots are full. Current: " + message = "Cannot purchase joker card, joker slots are full. Current: " .. gamestate.jokers.count .. ", Limit: " .. gamestate.jokers.limit, - error_code = BB_ERROR_NAMES.BAD_REQUEST, + name = BB_ERROR_NAMES.BAD_REQUEST, }) return end @@ -134,11 +143,11 @@ return { if card.set == "PLANET" or card.set == "SPECTRAL" or card.set == "TAROT" then if gamestate.consumables.count >= gamestate.consumables.limit then send_response({ - error = "Cannot purchase consumable card, consumable slots are full. Current: " + message = "Cannot purchase consumable card, consumable slots are full. Current: " .. gamestate.consumables.count .. ", Limit: " .. gamestate.consumables.limit, - error_code = BB_ERROR_NAMES.BAD_REQUEST, + name = BB_ERROR_NAMES.BAD_REQUEST, }) return end @@ -172,8 +181,8 @@ return { end if not btn then send_response({ - error = "No buy button found for card", - error_code = BB_ERROR_NAMES.NOT_ALLOWED, + message = "No buy button found for card", + name = BB_ERROR_NAMES.NOT_ALLOWED, }) return end diff --git a/src/lua/endpoints/cash_out.lua b/src/lua/endpoints/cash_out.lua index e89bc91..d2c6d37 100644 --- a/src/lua/endpoints/cash_out.lua +++ b/src/lua/endpoints/cash_out.lua @@ -1,10 +1,18 @@ -- src/lua/endpoints/cash_out.lua --- Cash Out Endpoint --- --- Cash out and collect round rewards + +-- ========================================================================== +-- CashOut Endpoint Params +-- ========================================================================== + +---@class Endpoint.CashOut.Params + +-- ========================================================================== +-- CashOut Endpoint +-- ========================================================================== ---@type Endpoint return { + name = "cash_out", description = "Cash out and collect round rewards", @@ -13,8 +21,8 @@ return { requires_state = { G.STATES.ROUND_EVAL }, - ---@param _ table The arguments (none required) - ---@param send_response fun(response: table) Callback to send response + ---@param _ Endpoint.CashOut.Params + ---@param send_response fun(response: EndpointResponse) execute = function(_, send_response) sendDebugMessage("Init cash_out()", "BB.ENDPOINTS") G.FUNCS.cash_out({ config = {} }) diff --git a/src/lua/endpoints/discard.lua b/src/lua/endpoints/discard.lua index 7725a74..da6de67 100644 --- a/src/lua/endpoints/discard.lua +++ b/src/lua/endpoints/discard.lua @@ -1,15 +1,23 @@ -- src/lua/endpoints/discard.lua --- Discard Endpoint --- --- Discard cards from the hand + +-- ========================================================================== +-- Discard Endpoint Params +-- ========================================================================== ---@class 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", @@ -18,31 +26,33 @@ return { description = "0-based indices of cards to discard", }, }, + requires_state = { G.STATES.SELECTING_HAND }, - ---@param args Endpoint.Discard.Params The arguments (cards) - ---@param send_response fun(response: table) Callback to send response + ---@param args Endpoint.Discard.Params + ---@param send_response fun(response: EndpointResponse) execute = function(args, send_response) + sendDebugMessage("Init discard()", "BB.ENDPOINTS") if #args.cards == 0 then send_response({ - error = "Must provide at least one card to discard", - error_code = BB_ERROR_NAMES.BAD_REQUEST, + 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({ - error = "No discards left", - error_code = BB_ERROR_NAMES.BAD_REQUEST, + message = "No discards left", + name = BB_ERROR_NAMES.BAD_REQUEST, }) return end if #args.cards > G.hand.config.highlighted_limit then send_response({ - error = "You can only discard " .. G.hand.config.highlighted_limit .. " cards", - error_code = BB_ERROR_NAMES.BAD_REQUEST, + message = "You can only discard " .. G.hand.config.highlighted_limit .. " cards", + name = BB_ERROR_NAMES.BAD_REQUEST, }) return end @@ -50,8 +60,8 @@ return { for _, card_index in ipairs(args.cards) do if not G.hand.cards[card_index + 1] then send_response({ - error = "Invalid card index: " .. card_index, - error_code = BB_ERROR_NAMES.BAD_REQUEST, + message = "Invalid card index: " .. card_index, + name = BB_ERROR_NAMES.BAD_REQUEST, }) return end @@ -86,6 +96,7 @@ return { 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 diff --git a/src/lua/endpoints/gamestate.lua b/src/lua/endpoints/gamestate.lua index 756c2af..a9a8a13 100644 --- a/src/lua/endpoints/gamestate.lua +++ b/src/lua/endpoints/gamestate.lua @@ -1,26 +1,32 @@ -- src/lua/endpoints/gamestate.lua + +-- ========================================================================== +-- Gamestate Endpoint Params +-- ========================================================================== + +---@class Endpoint.Gamestate.Params + +-- ========================================================================== -- Gamestate Endpoint --- --- Returns the current game state extracted via the gamestate utility --- Provides a simplified view of the game optimized for bot decision-making +-- ========================================================================== ---@type Endpoint return { + name = "gamestate", description = "Get current game state", - schema = {}, -- No arguments required + schema = {}, - requires_state = nil, -- Can be called from any state + requires_state = nil, - ---@param _ table The arguments (empty for gamestate) - ---@param send_response fun(response: table) Callback to send response + ---@param _ Endpoint.Gamestate.Params + ---@param send_response fun(response: EndpointResponse) execute = function(_, send_response) - -- Get current game state + sendDebugMessage("Init gamestate()", "BB.ENDPOINTS") local state_data = BB_GAMESTATE.get_gamestate() - - -- Return the game state + sendDebugMessage("Return gamestate()", "BB.ENDPOINTS") send_response(state_data) end, } diff --git a/src/lua/endpoints/health.lua b/src/lua/endpoints/health.lua index 17a776a..1232945 100644 --- a/src/lua/endpoints/health.lua +++ b/src/lua/endpoints/health.lua @@ -1,23 +1,31 @@ -- src/lua/endpoints/health.lua --- Health Check Endpoint --- --- Simple synchronous endpoint for connection testing and readiness checks --- Returns server status and basic game information immediately + +-- ========================================================================== +-- Health Endpoint Params +-- ========================================================================== + +---@class Endpoint.Health.Params + +-- ========================================================================== +-- Health Endpoint +-- ========================================================================== ---@type Endpoint return { + name = "health", description = "Health check endpoint for connection testing", - schema = {}, -- No arguments required + schema = {}, - requires_state = nil, -- Can be called from any state + requires_state = nil, - ---@param _ table The arguments (empty for health check) - ---@param send_response fun(response: table) Callback to send response + ---@param _ Endpoint.Health.Params + ---@param send_response fun(response: EndpointResponse) execute = function(_, send_response) - -- Return simple status immediately (synchronous) + sendDebugMessage("Init health()", "BB.ENDPOINTS") + sendDebugMessage("Return health()", "BB.ENDPOINTS") send_response({ status = "ok", }) diff --git a/src/lua/endpoints/load.lua b/src/lua/endpoints/load.lua index ee2fecc..191c7f4 100644 --- a/src/lua/endpoints/load.lua +++ b/src/lua/endpoints/load.lua @@ -1,15 +1,25 @@ -- src/lua/endpoints/load.lua --- Load Game State Endpoint --- --- Loads a saved game run state from a file using nativefs -local nativefs = require("nativefs") +-- ========================================================================== +-- Load Endpoint Params +-- ========================================================================== ---@class 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", @@ -24,8 +34,8 @@ return { requires_state = nil, - ---@param args Endpoint.Load.Params The arguments with 'path' field - ---@param send_response fun(response: table) Callback to send response + ---@param args Endpoint.Load.Params + ---@param send_response fun(response: EndpointResponse) execute = function(args, send_response) local path = args.path @@ -33,8 +43,8 @@ return { local file_info = nativefs.getInfo(path) if not file_info or file_info.type ~= "file" then send_response({ - error = "File not found: '" .. path .. "'", - error_code = BB_ERROR_NAMES.INTERNAL_ERROR, + message = "File not found: '" .. path .. "'", + name = BB_ERROR_NAMES.INTERNAL_ERROR, }) return end @@ -44,8 +54,8 @@ return { ---@cast compressed_data string if not compressed_data then send_response({ - error = "Failed to read save file", - error_code = BB_ERROR_NAMES.INTERNAL_ERROR, + message = "Failed to read save file", + name = BB_ERROR_NAMES.INTERNAL_ERROR, }) return end @@ -58,8 +68,8 @@ return { local write_success = nativefs.write(temp_path, compressed_data) if not write_success then send_response({ - error = "Failed to prepare save file for loading", - error_code = BB_ERROR_NAMES.INTERNAL_ERROR, + message = "Failed to prepare save file for loading", + name = BB_ERROR_NAMES.INTERNAL_ERROR, }) return end @@ -70,8 +80,8 @@ return { if G.SAVED_GAME == nil then send_response({ - error = "Invalid save file format", - error_code = BB_ERROR_NAMES.INTERNAL_ERROR, + message = "Invalid save file format", + name = BB_ERROR_NAMES.INTERNAL_ERROR, }) love.filesystem.remove(temp_filename) return @@ -128,8 +138,6 @@ return { done = num_items(G.shop_booster) > 0 or num_items(G.shop_jokers) > 0 or num_items(G.shop_vouchers) > 0 end - --- TODO: add other states here ... - if done then send_response({ success = true, diff --git a/src/lua/endpoints/menu.lua b/src/lua/endpoints/menu.lua index f2f4026..aa14f2f 100644 --- a/src/lua/endpoints/menu.lua +++ b/src/lua/endpoints/menu.lua @@ -1,10 +1,18 @@ -- src/lua/endpoints/menu.lua + +-- ========================================================================== +-- Menu Endpoint Params +-- ========================================================================== + +---@class Endpoint.Menu.Params + +-- ========================================================================== -- Menu Endpoint --- --- Returns to the main menu from any game state +-- ========================================================================== ---@type Endpoint return { + name = "menu", description = "Return to the main menu from any game state", @@ -13,8 +21,8 @@ return { requires_state = nil, - ---@param _ table The arguments (empty for menu) - ---@param send_response fun(response: table) Callback to send response + ---@param _ Endpoint.Menu.Params + ---@param send_response fun(response: EndpointResponse) execute = function(_, send_response) sendDebugMessage("Init menu()", "BB.ENDPOINTS") G.FUNCS.go_to_menu({}) diff --git a/src/lua/endpoints/next_round.lua b/src/lua/endpoints/next_round.lua index 87bab35..c919d11 100644 --- a/src/lua/endpoints/next_round.lua +++ b/src/lua/endpoints/next_round.lua @@ -1,10 +1,18 @@ -- src/lua/endpoints/next_round.lua --- Next Round Endpoint --- --- Leave the shop and advance to blind selection + +-- ========================================================================== +-- NextRound Endpoint Params +-- ========================================================================== + +---@class Endpoint.NextRound.Params + +-- ========================================================================== +-- NextRound Endpoint +-- ========================================================================== ---@type Endpoint return { + name = "next_round", description = "Leave the shop and advance to blind selection", @@ -13,8 +21,8 @@ return { requires_state = { G.STATES.SHOP }, - ---@param _ table The arguments (none required) - ---@param send_response fun(response: table) Callback to send response + ---@param _ Endpoint.NextRound.Params + ---@param send_response fun(response: EndpointResponse) execute = function(_, send_response) sendDebugMessage("Init next_round()", "BB.ENDPOINTS") G.FUNCS.toggle_shop({}) diff --git a/src/lua/endpoints/play.lua b/src/lua/endpoints/play.lua index 6e22fe7..0a452e3 100644 --- a/src/lua/endpoints/play.lua +++ b/src/lua/endpoints/play.lua @@ -1,15 +1,23 @@ -- src/lua/endpoints/play.lua --- Play Endpoint --- --- Play a card from the hand + +-- ========================================================================== +-- Play Endpoint Params +-- ========================================================================== ---@class 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", @@ -18,23 +26,25 @@ return { description = "0-based indices of cards to play", }, }, + requires_state = { G.STATES.SELECTING_HAND }, - ---@param args Endpoint.Play.Params The arguments (cards) - ---@param send_response fun(response: table) Callback to send response + ---@param args Endpoint.Play.Params + ---@param send_response fun(response: EndpointResponse) execute = function(args, send_response) + sendDebugMessage("Init play()", "BB.ENDPOINTS") if #args.cards == 0 then send_response({ - error = "Must provide at least one card to play", - error_code = BB_ERROR_NAMES.BAD_REQUEST, + 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({ - error = "You can only play " .. G.hand.config.highlighted_limit .. " cards", - error_code = BB_ERROR_NAMES.BAD_REQUEST, + message = "You can only play " .. G.hand.config.highlighted_limit .. " cards", + name = BB_ERROR_NAMES.BAD_REQUEST, }) return end @@ -42,8 +52,8 @@ return { for _, card_index in ipairs(args.cards) do if not G.hand.cards[card_index + 1] then send_response({ - error = "Invalid card index: " .. card_index, - error_code = BB_ERROR_NAMES.BAD_REQUEST, + message = "Invalid card index: " .. card_index, + name = BB_ERROR_NAMES.BAD_REQUEST, }) return end diff --git a/src/lua/endpoints/rearrange.lua b/src/lua/endpoints/rearrange.lua index d8499e7..47ca338 100644 --- a/src/lua/endpoints/rearrange.lua +++ b/src/lua/endpoints/rearrange.lua @@ -1,18 +1,25 @@ -- src/lua/endpoints/rearrange.lua --- Rearrange Endpoint --- --- Rearrange cards in hand, jokers, or consumables + +-- ========================================================================== +-- Rearrange Endpoint Params +-- ========================================================================== ---@class 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 --- Exactly one parameter must be provided + +-- ========================================================================== +-- Rearrange Endpoint +-- ========================================================================== ---@type Endpoint return { + name = "rearrange", + description = "Rearrange cards in hand, jokers, or consumables", + schema = { hand = { type = "array", @@ -33,23 +40,24 @@ return { description = "0-based indices representing new order of consumables", }, }, + requires_state = { G.STATES.SELECTING_HAND, G.STATES.SHOP }, - ---@param args Endpoint.Rearrange.Params The arguments (hand, jokers, or consumables) - ---@param send_response fun(response: table) Callback to send response + ---@param args Endpoint.Rearrange.Params + ---@param send_response fun(response: EndpointResponse) 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({ - error = "Must provide exactly one of: hand, jokers, or consumables", - error_code = BB_ERROR_NAMES.BAD_REQUEST, + message = "Must provide exactly one of: hand, jokers, or consumables", + name = BB_ERROR_NAMES.BAD_REQUEST, }) return elseif param_count > 1 then send_response({ - error = "Can only rearrange one type at a time", - error_code = BB_ERROR_NAMES.BAD_REQUEST, + message = "Can only rearrange one type at a time", + name = BB_ERROR_NAMES.BAD_REQUEST, }) return end @@ -61,8 +69,8 @@ return { -- Cards can only be rearranged during SELECTING_HAND if G.STATE ~= G.STATES.SELECTING_HAND then send_response({ - error = "Can only rearrange hand during hand selection", - error_code = BB_ERROR_NAMES.INVALID_STATE, + message = "Can only rearrange hand during hand selection", + name = BB_ERROR_NAMES.INVALID_STATE, }) return end @@ -70,8 +78,8 @@ return { -- Validate G.hand exists (not tested) if not G.hand or not G.hand.cards then send_response({ - error = "No hand available to rearrange", - error_code = BB_ERROR_NAMES.NOT_ALLOWED, + message = "No hand available to rearrange", + name = BB_ERROR_NAMES.NOT_ALLOWED, }) return end @@ -84,8 +92,8 @@ return { -- Validate G.jokers exists (not tested) if not G.jokers or not G.jokers.cards then send_response({ - error = "No jokers available to rearrange", - error_code = BB_ERROR_NAMES.NOT_ALLOWED, + message = "No jokers available to rearrange", + name = BB_ERROR_NAMES.NOT_ALLOWED, }) return end @@ -98,8 +106,8 @@ return { -- Validate G.consumeables exists (not tested) if not G.consumeables or not G.consumeables.cards then send_response({ - error = "No consumables available to rearrange", - error_code = BB_ERROR_NAMES.NOT_ALLOWED, + message = "No consumables available to rearrange", + name = BB_ERROR_NAMES.NOT_ALLOWED, }) return end @@ -116,8 +124,8 @@ return { -- Check length matches if #indices ~= #source_array then send_response({ - error = "Must provide exactly " .. #source_array .. " indices for " .. type_name, - error_code = BB_ERROR_NAMES.BAD_REQUEST, + message = "Must provide exactly " .. #source_array .. " indices for " .. type_name, + name = BB_ERROR_NAMES.BAD_REQUEST, }) return end @@ -128,8 +136,8 @@ return { -- Check range [0, N-1] if idx < 0 or idx >= #source_array then send_response({ - error = "Index out of range for " .. type_name .. ": " .. idx, - error_code = BB_ERROR_NAMES.BAD_REQUEST, + message = "Index out of range for " .. type_name .. ": " .. idx, + name = BB_ERROR_NAMES.BAD_REQUEST, }) return end @@ -137,8 +145,8 @@ return { -- Check for duplicates if seen[idx] then send_response({ - error = "Duplicate index in " .. type_name .. ": " .. idx, - error_code = BB_ERROR_NAMES.BAD_REQUEST, + message = "Duplicate index in " .. type_name .. ": " .. idx, + name = BB_ERROR_NAMES.BAD_REQUEST, }) return end @@ -193,7 +201,7 @@ return { end if done then - sendDebugMessage("rearrange() completed", "BB.ENDPOINTS") + sendDebugMessage("Return rearrange()", "BB.ENDPOINTS") local state_data = BB_GAMESTATE.get_gamestate() send_response(state_data) end diff --git a/src/lua/endpoints/reroll.lua b/src/lua/endpoints/reroll.lua index ce54856..9383a5b 100644 --- a/src/lua/endpoints/reroll.lua +++ b/src/lua/endpoints/reroll.lua @@ -1,10 +1,18 @@ -- src/lua/endpoints/reroll.lua + +-- ========================================================================== +-- Reroll Endpoint Params +-- ========================================================================== + +---@class Endpoint.Reroll.Params + +-- ========================================================================== -- Reroll Endpoint --- --- Reroll to update the cards in the shop area +-- ========================================================================== ---@type Endpoint return { + name = "reroll", description = "Reroll to update the cards in the shop area", @@ -13,16 +21,16 @@ return { requires_state = { G.STATES.SHOP }, - ---@param _ table The arguments (none required) - ---@param send_response fun(response: table) Callback to send response + ---@param _ Endpoint.Reroll.Params + ---@param send_response fun(response: EndpointResponse) execute = function(_, send_response) -- Check affordability local reroll_cost = G.GAME.current_round and G.GAME.current_round.reroll_cost or 0 if G.GAME.dollars < reroll_cost then send_response({ - error = "Not enough dollars to reroll. Current: " .. G.GAME.dollars .. ", Required: " .. reroll_cost, - error_code = BB_ERROR_NAMES.NOT_ALLOWED, + message = "Not enough dollars to reroll. Current: " .. G.GAME.dollars .. ", Required: " .. reroll_cost, + name = BB_ERROR_NAMES.NOT_ALLOWED, }) return end diff --git a/src/lua/endpoints/save.lua b/src/lua/endpoints/save.lua index 36bbd9e..872f342 100644 --- a/src/lua/endpoints/save.lua +++ b/src/lua/endpoints/save.lua @@ -1,15 +1,25 @@ -- src/lua/endpoints/save.lua --- Save Game State Endpoint --- --- Saves the current game run state to a file using nativefs -local nativefs = require("nativefs") +-- ========================================================================== +-- Save Endpoint Params +-- ========================================================================== ---@class 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", @@ -22,35 +32,33 @@ return { }, }, - -- All states that occur during an active run (G.STAGES.RUN) - -- Excludes: MENU, SPLASH, SANDBOX, TUTORIAL, DEMO_CTA requires_state = { - G.STATES.SELECTING_HAND, -- 1 - G.STATES.HAND_PLAYED, -- 2 - G.STATES.DRAW_TO_HAND, -- 3 - G.STATES.GAME_OVER, -- 4 - G.STATES.SHOP, -- 5 - G.STATES.PLAY_TAROT, -- 6 - G.STATES.BLIND_SELECT, -- 7 - G.STATES.ROUND_EVAL, -- 8 - G.STATES.TAROT_PACK, -- 9 - G.STATES.PLANET_PACK, -- 10 - G.STATES.SPECTRAL_PACK, -- 15 - G.STATES.STANDARD_PACK, -- 17 - G.STATES.BUFFOON_PACK, -- 18 - G.STATES.NEW_ROUND, -- 19 + 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 Endpoint.Save.Params The arguments with 'path' field - ---@param send_response fun(response: table) Callback to send response + ---@param args Endpoint.Save.Params + ---@param send_response fun(response: EndpointResponse) 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({ - error = "Can only save during an active run", - error_code = BB_ERROR_NAMES.INVALID_STATE, + message = "Can only save during an active run", + name = BB_ERROR_NAMES.INVALID_STATE, }) return end @@ -69,8 +77,8 @@ return { if not compressed_data then send_response({ - error = "Failed to save game state", - error_code = BB_ERROR_NAMES.INTERNAL_ERROR, + message = "Failed to save game state", + name = BB_ERROR_NAMES.INTERNAL_ERROR, }) return end @@ -78,8 +86,8 @@ return { local write_success = nativefs.write(path, compressed_data) if not write_success then send_response({ - error = "Failed to write save file to '" .. path .. "'", - error_code = BB_ERROR_NAMES.INTERNAL_ERROR, + message = "Failed to write save file to '" .. path .. "'", + name = BB_ERROR_NAMES.INTERNAL_ERROR, }) return end diff --git a/src/lua/endpoints/select.lua b/src/lua/endpoints/select.lua index 09cc584..5b1dafc 100644 --- a/src/lua/endpoints/select.lua +++ b/src/lua/endpoints/select.lua @@ -1,13 +1,30 @@ +-- src/lua/endpoints/select.lua + +-- ========================================================================== +-- Select Endpoint Params +-- ========================================================================== + +---@class Endpoint.Select.Params + +-- ========================================================================== +-- Select Endpoint +-- ========================================================================== + ---@type Endpoint return { + name = "select", + description = "Select the current blind", + schema = {}, + requires_state = { G.STATES.BLIND_SELECT }, - ---@param _ table The arguments (none required) - ---@param send_response fun(response: table) Callback to send response + ---@param _ Endpoint.Select.Params + ---@param send_response fun(response: EndpointResponse) 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") @@ -26,7 +43,7 @@ return { func = function() local done = G.STATE == G.STATES.SELECTING_HAND and G.hand ~= nil if done then - sendDebugMessage("select() completed", "BB.ENDPOINTS") + sendDebugMessage("Return select()", "BB.ENDPOINTS") local state_data = BB_GAMESTATE.get_gamestate() send_response(state_data) end diff --git a/src/lua/endpoints/sell.lua b/src/lua/endpoints/sell.lua index 0226527..e21a9f1 100644 --- a/src/lua/endpoints/sell.lua +++ b/src/lua/endpoints/sell.lua @@ -1,18 +1,24 @@ -- src/lua/endpoints/sell.lua --- Sell Endpoint --- --- Sell a joker or consumable from player inventory + +-- ========================================================================== +-- Sell Endpoint Params +-- ========================================================================== ---@class Endpoint.Sell.Params ---@field joker integer? 0-based index of joker to sell ---@field consumable integer? 0-based index of consumable to sell --- One (and only one) parameter is required --- Must be in SHOP or SELECTING_HAND state + +-- ========================================================================== +-- Sell Endpoint +-- ========================================================================== ---@type Endpoint return { + name = "sell", + description = "Sell a joker or consumable from player inventory", + schema = { joker = { type = "integer", @@ -25,10 +31,11 @@ return { description = "0-based index of consumable to sell", }, }, + requires_state = { G.STATES.SELECTING_HAND, G.STATES.SHOP }, - ---@param args Endpoint.Sell.Params The arguments (joker or consumable) - ---@param send_response fun(response: table) Callback to send response + ---@param args Endpoint.Sell.Params + ---@param send_response fun(response: EndpointResponse) execute = function(args, send_response) sendDebugMessage("Init sell()", "BB.ENDPOINTS") @@ -36,14 +43,14 @@ return { local param_count = (args.joker and 1 or 0) + (args.consumable and 1 or 0) if param_count == 0 then send_response({ - error = "Must provide exactly one of: joker or consumable", - error_code = BB_ERROR_NAMES.BAD_REQUEST, + message = "Must provide exactly one of: joker or consumable", + name = BB_ERROR_NAMES.BAD_REQUEST, }) return elseif param_count > 1 then send_response({ - error = "Can only sell one item at a time", - error_code = BB_ERROR_NAMES.BAD_REQUEST, + message = "Can only sell one item at a time", + name = BB_ERROR_NAMES.BAD_REQUEST, }) return end @@ -55,8 +62,8 @@ return { -- 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({ - error = "No jokers available to sell", - error_code = BB_ERROR_NAMES.NOT_ALLOWED, + message = "No jokers available to sell", + name = BB_ERROR_NAMES.NOT_ALLOWED, }) return end @@ -67,8 +74,8 @@ return { -- 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({ - error = "No consumables available to sell", - error_code = BB_ERROR_NAMES.NOT_ALLOWED, + message = "No consumables available to sell", + name = BB_ERROR_NAMES.NOT_ALLOWED, }) return end @@ -80,8 +87,8 @@ return { -- Validate card exists at index if not source_array[pos] then send_response({ - error = "Index out of range for " .. sell_type .. ": " .. (pos - 1), - error_code = BB_ERROR_NAMES.BAD_REQUEST, + message = "Index out of range for " .. sell_type .. ": " .. (pos - 1), + name = BB_ERROR_NAMES.BAD_REQUEST, }) return end @@ -137,7 +144,7 @@ return { -- All conditions must be met if count_decreased and money_increased and card_gone and state_stable and valid_state then - sendDebugMessage("Sell completed successfully", "BB.ENDPOINTS") + sendDebugMessage("Return sell()", "BB.ENDPOINTS") send_response(BB_GAMESTATE.get_gamestate()) return true end diff --git a/src/lua/endpoints/set.lua b/src/lua/endpoints/set.lua index f44ea90..cd2c7b1 100644 --- a/src/lua/endpoints/set.lua +++ b/src/lua/endpoints/set.lua @@ -1,4 +1,8 @@ --- Set a in-game value +-- src/lua/endpoints/set.lua + +-- ========================================================================== +-- Set Endpoint Params +-- ========================================================================== ---@class Endpoint.Set.Params ---@field money integer? New money amount @@ -9,10 +13,17 @@ ---@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", @@ -50,18 +61,19 @@ return { description = "Re-stock shop with new items", }, }, + requires_state = nil, - ---@param args Endpoint.Set.Params The arguments - ---@param send_response fun(response: table) Callback to send response + ---@param args Endpoint.Set.Params + ---@param send_response fun(response: EndpointResponse) 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({ - error = "Can only set during an active run", - error_code = BB_ERROR_NAMES.INVALID_STATE, + message = "Can only set during an active run", + name = BB_ERROR_NAMES.INVALID_STATE, }) return end @@ -77,8 +89,8 @@ return { and args.shop == nil then send_response({ - error = "Must provide at least one field to set", - error_code = BB_ERROR_NAMES.BAD_REQUEST, + message = "Must provide at least one field to set", + name = BB_ERROR_NAMES.BAD_REQUEST, }) return end @@ -87,8 +99,8 @@ return { if args.money then if args.money < 0 then send_response({ - error = "Money must be a positive integer", - error_code = BB_ERROR_NAMES.BAD_REQUEST, + message = "Money must be a positive integer", + name = BB_ERROR_NAMES.BAD_REQUEST, }) return end @@ -100,8 +112,8 @@ return { if args.chips then if args.chips < 0 then send_response({ - error = "Chips must be a positive integer", - error_code = BB_ERROR_NAMES.BAD_REQUEST, + message = "Chips must be a positive integer", + name = BB_ERROR_NAMES.BAD_REQUEST, }) return end @@ -113,8 +125,8 @@ return { if args.ante then if args.ante < 0 then send_response({ - error = "Ante must be a positive integer", - error_code = BB_ERROR_NAMES.BAD_REQUEST, + message = "Ante must be a positive integer", + name = BB_ERROR_NAMES.BAD_REQUEST, }) return end @@ -126,8 +138,8 @@ return { if args.round then if args.round < 0 then send_response({ - error = "Round must be a positive integer", - error_code = BB_ERROR_NAMES.BAD_REQUEST, + message = "Round must be a positive integer", + name = BB_ERROR_NAMES.BAD_REQUEST, }) return end @@ -139,8 +151,8 @@ return { if args.hands then if args.hands < 0 then send_response({ - error = "Hands must be a positive integer", - error_code = BB_ERROR_NAMES.BAD_REQUEST, + message = "Hands must be a positive integer", + name = BB_ERROR_NAMES.BAD_REQUEST, }) return end @@ -152,8 +164,8 @@ return { if args.discards then if args.discards < 0 then send_response({ - error = "Discards must be a positive integer", - error_code = BB_ERROR_NAMES.BAD_REQUEST, + message = "Discards must be a positive integer", + name = BB_ERROR_NAMES.BAD_REQUEST, }) return end @@ -164,8 +176,8 @@ return { if args.shop then if G.STATE ~= G.STATES.SHOP then send_response({ - error = "Can re-stock shop only in SHOP state", - error_code = BB_ERROR_NAMES.NOT_ALLOWED, + message = "Can re-stock shop only in SHOP state", + name = BB_ERROR_NAMES.NOT_ALLOWED, }) return end diff --git a/src/lua/endpoints/skip.lua b/src/lua/endpoints/skip.lua index 9df64ff..7fbdc5b 100644 --- a/src/lua/endpoints/skip.lua +++ b/src/lua/endpoints/skip.lua @@ -1,17 +1,28 @@ -- src/lua/endpoints/skip.lua + +-- ========================================================================== +-- Skip Endpoint Params +-- ========================================================================== + +---@class Endpoint.Skip.Params + +-- ========================================================================== -- Skip Endpoint --- --- Skip the current blind (Small or Big only, not Boss) +-- ========================================================================== ---@type Endpoint return { + name = "skip", + description = "Skip the current blind (Small or Big only, not Boss)", + schema = {}, + requires_state = { G.STATES.BLIND_SELECT }, - ---@param _ table The arguments (none required) - ---@param send_response fun(response: table) Callback to send response + ---@param _ Endpoint.Skip.Params + ---@param send_response fun(response: EndpointResponse) execute = function(_, send_response) sendDebugMessage("Init skip()", "BB.ENDPOINTS") @@ -25,8 +36,8 @@ return { if blind.type == "BOSS" then sendDebugMessage("skip() cannot skip Boss blind: " .. current_blind, "BB.ENDPOINTS") send_response({ - error = "Cannot skip Boss blind", - error_code = BB_ERROR_NAMES.NOT_ALLOWED, + message = "Cannot skip Boss blind", + name = BB_ERROR_NAMES.NOT_ALLOWED, }) return end diff --git a/src/lua/endpoints/start.lua b/src/lua/endpoints/start.lua index 48121ac..8a7fbed 100644 --- a/src/lua/endpoints/start.lua +++ b/src/lua/endpoints/start.lua @@ -1,9 +1,18 @@ -- src/lua/endpoints/start.lua --- Start Endpoint --- --- Starts a new game run with specified deck and stake --- Mapping tables for enum values +-- ========================================================================== +-- Start Endpoint Params +-- ========================================================================== + +---@class 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", @@ -33,13 +42,13 @@ local STAKE_ENUM_TO_NUMBER = { GOLD = 8, } ----@class Endpoint.Run.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 +-- ========================================================================== ---@type Endpoint return { + name = "start", description = "Start a new game run with specified deck and stake", @@ -64,8 +73,8 @@ return { requires_state = { G.STATES.MENU }, - ---@param args Endpoint.Run.Params The arguments (deck, stake, seed?) - ---@param send_response fun(response: table) Callback to send response + ---@param args Endpoint.Start.Params + ---@param send_response fun(response: EndpointResponse) execute = function(args, send_response) sendDebugMessage("Init start()", "BB.ENDPOINTS") @@ -74,9 +83,9 @@ return { if not stake_number then sendDebugMessage("start() called with invalid stake enum: " .. tostring(args.stake), "BB.ENDPOINTS") send_response({ - error = "Invalid stake enum. Must be one of: WHITE, RED, GREEN, BLACK, BLUE, PURPLE, ORANGE, GOLD. Got: " + message = "Invalid stake enum. Must be one of: WHITE, RED, GREEN, BLACK, BLUE, PURPLE, ORANGE, GOLD. Got: " .. tostring(args.stake), - error_code = BB_ERROR_NAMES.BAD_REQUEST, + name = BB_ERROR_NAMES.BAD_REQUEST, }) return end @@ -86,9 +95,9 @@ return { if not deck_name then sendDebugMessage("start() called with invalid deck enum: " .. tostring(args.deck), "BB.ENDPOINTS") send_response({ - error = "Invalid deck enum. Must be one of: RED, BLUE, YELLOW, GREEN, BLACK, MAGIC, NEBULA, GHOST, ABANDONED, CHECKERED, ZODIAC, PAINTED, ANAGLYPH, PLASMA, ERRATIC. Got: " + 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), - error_code = BB_ERROR_NAMES.BAD_REQUEST, + name = BB_ERROR_NAMES.BAD_REQUEST, }) return end @@ -114,8 +123,8 @@ return { if not deck_found then sendDebugMessage("start() deck not found in game data: " .. deck_name, "BB.ENDPOINTS") send_response({ - error = "Deck not found in game data: " .. deck_name, - error_code = BB_ERROR_NAMES.INTERNAL_ERROR, + message = "Deck not found in game data: " .. deck_name, + name = BB_ERROR_NAMES.INTERNAL_ERROR, }) return end diff --git a/src/lua/endpoints/tests/echo.lua b/src/lua/endpoints/tests/echo.lua index 32cad1c..8e8e2b4 100644 --- a/src/lua/endpoints/tests/echo.lua +++ b/src/lua/endpoints/tests/echo.lua @@ -1,62 +1,65 @@ -- src/lua/endpoints/tests/echo.lua --- Test Endpoint for Dispatcher Testing --- --- Simplified endpoint for testing the dispatcher with the simplified validator ----@class TestEndpoint.Echo.Params +-- ========================================================================== +-- Test Echo Endpoint Params +-- ========================================================================== + +---@class 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 field required_string = { type = "string", required = true, description = "A required string field", }, - -- Optional string field optional_string = { type = "string", + required = false, description = "Optional string field", }, - -- Required integer field required_integer = { type = "integer", required = true, description = "Required integer field", }, - -- Optional integer field optional_integer = { type = "integer", + required = false, description = "Optional integer field", }, - -- Optional array of integers optional_array_integers = { type = "array", + required = false, items = "integer", description = "Optional array of integers", }, }, - requires_state = nil, -- Can be called from any state + requires_state = nil, - ---@param args TestEndpoint.Echo.Params The validated arguments - ---@param send_response fun(response: table) Callback to send response + ---@param args Endpoint.Test.Echo.Params + ---@param send_response fun(response: EndpointResponse) execute = function(args, send_response) - -- Echo back the received arguments send_response({ success = true, received_args = args, diff --git a/src/lua/endpoints/tests/endpoint.lua b/src/lua/endpoints/tests/endpoint.lua index 9ed735a..0b88299 100644 --- a/src/lua/endpoints/tests/endpoint.lua +++ b/src/lua/endpoints/tests/endpoint.lua @@ -1,62 +1,65 @@ --- tests/lua/endpoints/tests/endpoint.lua --- Test Endpoint for Dispatcher Testing --- --- Simplified endpoint for testing the dispatcher with the simplified validator +-- src/lua/endpoints/tests/endpoint.lua ----@class TestEndpoint.Endpoint.Params +-- ========================================================================== +-- Test Endpoint Endpoint Params +-- ========================================================================== + +---@class 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 field required_string = { type = "string", required = true, description = "A required string field", }, - -- Optional string field optional_string = { type = "string", + required = false, description = "Optional string field", }, - -- Required integer field required_integer = { type = "integer", required = true, description = "Required integer field", }, - -- Optional integer field optional_integer = { type = "integer", + required = false, description = "Optional integer field", }, - -- Optional array of integers optional_array_integers = { type = "array", + required = false, items = "integer", description = "Optional array of integers", }, }, - requires_state = nil, -- Can be called from any state + requires_state = nil, - ---@param args TestEndpoint.Endpoint.Params The validated arguments - ---@param send_response fun(response: table) Callback to send response + ---@param args Endpoint.Test.Endpoint.Params + ---@param send_response fun(response: EndpointResponse) execute = function(args, send_response) - -- Echo back the received arguments send_response({ success = true, received_args = args, diff --git a/src/lua/endpoints/tests/error.lua b/src/lua/endpoints/tests/error.lua index 19298fb..6b396d9 100644 --- a/src/lua/endpoints/tests/error.lua +++ b/src/lua/endpoints/tests/error.lua @@ -1,13 +1,19 @@ -- src/lua/endpoints/tests/error.lua --- Test Endpoint that Throws Errors --- --- Used for testing TIER 4: Execution Error Handling ----@class TestEndpoint.Error.Params +-- ========================================================================== +-- Test Error Endpoint Params +-- ========================================================================== + +---@class 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", @@ -23,8 +29,8 @@ return { requires_state = nil, - ---@param args TestEndpoint.Error.Params The arguments - ---@param send_response fun(response: table) Callback to send response + ---@param args Endpoint.Test.Error.Params + ---@param send_response fun(response: EndpointResponse) execute = function(args, send_response) if args.error_type == "throw_error" then error("Intentional test error from endpoint execution") diff --git a/src/lua/endpoints/tests/state.lua b/src/lua/endpoints/tests/state.lua index 66082ae..d3fc4d6 100644 --- a/src/lua/endpoints/tests/state.lua +++ b/src/lua/endpoints/tests/state.lua @@ -1,24 +1,28 @@ -- src/lua/endpoints/tests/state.lua --- Test Endpoint with State Requirements --- --- Used for testing TIER 3: Game State Validation ----@class TestEndpoint.State.Params --- Empty params - this endpoint has no arguments +-- ========================================================================== +-- Test State Endpoint Params +-- ========================================================================== + +---@class Endpoint.Test.State.Params + +-- ========================================================================== +-- TestState Endpoint +-- ========================================================================== ---@type Endpoint return { + name = "test_state_endpoint", description = "Test endpoint that requires specific game states", - schema = {}, -- No argument validation + schema = {}, - -- This endpoint can only be called from SPLASH or MENU states - requires_state = { "SPLASH", "MENU" }, + requires_state = { G.STATES.SPLASH, G.STATES.MENU }, - ---@param _ TestEndpoint.State.Params The arguments (empty) - ---@param send_response fun(response: table) Callback to send response + ---@param _ Endpoint.Test.State.Params + ---@param send_response fun(response: EndpointResponse) execute = function(_, send_response) send_response({ success = true, diff --git a/src/lua/endpoints/tests/validation.lua b/src/lua/endpoints/tests/validation.lua index 12cbffc..78857b5 100644 --- a/src/lua/endpoints/tests/validation.lua +++ b/src/lua/endpoints/tests/validation.lua @@ -1,12 +1,10 @@ --- tests/lua/endpoints/test_validation.lua --- Comprehensive Validation Test Endpoint --- --- Endpoint with schema for testing simplified validator capabilities: --- - Type validation (string, integer, boolean, array, table) --- - Required field validation --- - Array item type validation (integer arrays only) +-- src/lua/endpoints/tests/validation.lua ----@class TestEndpoint.Validation.Params +-- ========================================================================== +-- Test Validation Endpoint Params +-- ========================================================================== + +---@class 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 @@ -15,60 +13,67 @@ ---@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 (only required field in the schema) required_field = { type = "string", required = true, description = "Required string field for basic validation testing", }, - -- Type validation fields 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 item type validation array_of_integers = { type = "array", + required = false, items = "integer", description = "Optional array that must contain only integers", }, }, - requires_state = nil, -- Can be called from any state + requires_state = nil, - ---@param args TestEndpoint.Validation.Params The validated arguments - ---@param send_response fun(response: table) Callback to send response + ---@param args Endpoint.Test.Validation.Params + ---@param send_response fun(response: EndpointResponse) execute = function(args, send_response) - -- Simply return success with the received arguments send_response({ success = true, received_args = args, diff --git a/src/lua/endpoints/use.lua b/src/lua/endpoints/use.lua index 25e1c8b..84c153f 100644 --- a/src/lua/endpoints/use.lua +++ b/src/lua/endpoints/use.lua @@ -1,15 +1,24 @@ --- Use Endpoint --- --- Use a consumable card (Tarot, Planet, or Spectral) with optional target cards +-- src/lua/endpoints/use.lua + +-- ========================================================================== +-- Use Endpoint Params +-- ========================================================================== ---@class 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", @@ -23,18 +32,19 @@ return { items = "integer", }, }, + requires_state = { G.STATES.SELECTING_HAND, G.STATES.SHOP }, - ---@param args Endpoint.Use.Params The arguments - ---@param send_response fun(response: table) Callback to send response + ---@param args Endpoint.Use.Params + ---@param send_response fun(response: EndpointResponse) 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({ - error = "Consumable index out of range: " .. args.consumable, - error_code = BB_ERROR_NAMES.BAD_REQUEST, + message = "Consumable index out of range: " .. args.consumable, + name = BB_ERROR_NAMES.BAD_REQUEST, }) return end @@ -47,10 +57,10 @@ return { -- Step 3: State Validation for Card-Selecting Consumables if requires_cards and G.STATE ~= G.STATES.SELECTING_HAND then send_response({ - error = "Consumable '" + message = "Consumable '" .. consumable_card.ability.name .. "' requires card selection and can only be used in SELECTING_HAND state", - error_code = BB_ERROR_NAMES.INVALID_STATE, + name = BB_ERROR_NAMES.INVALID_STATE, }) return end @@ -59,8 +69,8 @@ return { if requires_cards then if not args.cards or #args.cards == 0 then send_response({ - error = "Consumable '" .. consumable_card.ability.name .. "' requires card selection", - error_code = BB_ERROR_NAMES.BAD_REQUEST, + message = "Consumable '" .. consumable_card.ability.name .. "' requires card selection", + name = BB_ERROR_NAMES.BAD_REQUEST, }) return end @@ -69,8 +79,8 @@ return { for _, card_idx in ipairs(args.cards) do if card_idx < 0 or card_idx >= #G.hand.cards then send_response({ - error = "Card index out of range: " .. card_idx, - error_code = BB_ERROR_NAMES.BAD_REQUEST, + message = "Card index out of range: " .. card_idx, + name = BB_ERROR_NAMES.BAD_REQUEST, }) return end @@ -86,14 +96,14 @@ return { -- Check if consumable requires exact number of cards if min_cards == max_cards and card_count ~= min_cards then send_response({ - error = string.format( + 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 ), - error_code = BB_ERROR_NAMES.BAD_REQUEST, + name = BB_ERROR_NAMES.BAD_REQUEST, }) return end @@ -101,28 +111,28 @@ return { -- For consumables with range, check min and max separately if card_count < min_cards then send_response({ - error = string.format( + 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 ), - error_code = BB_ERROR_NAMES.BAD_REQUEST, + name = BB_ERROR_NAMES.BAD_REQUEST, }) return end if card_count > max_cards then send_response({ - error = string.format( + 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 ), - error_code = BB_ERROR_NAMES.BAD_REQUEST, + name = BB_ERROR_NAMES.BAD_REQUEST, }) return end @@ -150,8 +160,8 @@ return { -- 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({ - error = "Consumable '" .. consumable_card.ability.name .. "' cannot be used at this time", - error_code = BB_ERROR_NAMES.NOT_ALLOWED, + message = "Consumable '" .. consumable_card.ability.name .. "' cannot be used at this time", + name = BB_ERROR_NAMES.NOT_ALLOWED, }) return end @@ -159,8 +169,8 @@ return { -- Step 8: Space Check (not tested) if consumable_card:check_use() then send_response({ - error = "Cannot use consumable '" .. consumable_card.ability.name .. "': insufficient space", - error_code = BB_ERROR_NAMES.NOT_ALLOWED, + message = "Cannot use consumable '" .. consumable_card.ability.name .. "': insufficient space", + name = BB_ERROR_NAMES.NOT_ALLOWED, }) return end @@ -199,7 +209,7 @@ return { local no_stop_use = not (G.GAME.STOP_USE and G.GAME.STOP_USE > 0) if card_removed and state_restored and controller_unlocked and no_stop_use then - sendDebugMessage("use() completed successfully", "BB.ENDPOINTS") + sendDebugMessage("Return use()", "BB.ENDPOINTS") send_response(BB_GAMESTATE.get_gamestate()) return true end diff --git a/src/lua/settings.lua b/src/lua/settings.lua index 9fb797d..e19f2ed 100644 --- a/src/lua/settings.lua +++ b/src/lua/settings.lua @@ -28,27 +28,18 @@ BalatroBot configure settings in Balatro using the following environment variabl ---@diagnostic disable: duplicate-set-field ----@class BB_SETTINGS +---@type Settings BB_SETTINGS = { - ---@type string host = os.getenv("BALATROBOT_HOST") or "127.0.0.1", - ---@type number port = tonumber(os.getenv("BALATROBOT_PORT")) or 12346, - ---@type boolean headless = os.getenv("BALATROBOT_HEADLESS") == "1" or false, - ---@type boolean fast = os.getenv("BALATROBOT_FAST") == "1" or false, - ---@type boolean render_on_api = os.getenv("BALATROBOT_RENDER_ON_API") == "1" or false, - ---@type boolean audio = os.getenv("BALATROBOT_AUDIO") == "1" or false, - ---@type boolean debug = os.getenv("BALATROBOT_DEBUG") == "1" or false, - ---@type boolean no_shaders = os.getenv("BALATROBOT_NO_SHADERS") == "1" or false, } --- Global flag to trigger rendering (used by render_on_api) ---@type boolean? BB_RENDER = nil diff --git a/src/lua/utils/debugger.lua b/src/lua/utils/debugger.lua index f86166b..4a3c262 100644 --- a/src/lua/utils/debugger.lua +++ b/src/lua/utils/debugger.lua @@ -109,9 +109,8 @@ BB_API = setmetatable({}, { end, }) +---@type Debug BB_DEBUG = { - -- Logger instance (set by setup if DebugPlus is available) - ---@type table? log = nil, } --- Initializes DebugPlus integration if available diff --git a/src/lua/utils/errors.lua b/src/lua/utils/errors.lua index e826e32..dbe6707 100644 --- a/src/lua/utils/errors.lua +++ b/src/lua/utils/errors.lua @@ -1,21 +1,7 @@ --- src/lua/utils/errors.lua --- Error definitions for BalatroBot API - ----@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 ErrorNames table - ----@alias ErrorCode ----| -32000 # INTERNAL_ERROR ----| -32001 # BAD_REQUEST ----| -32002 # INVALID_STATE ----| -32003 # NOT_ALLOWED - ----@alias ErrorCodes table +--[[ + Error definitions for BalatroBot API. + Type aliases defined in types.lua. +]] ---@type ErrorNames BB_ERROR_NAMES = { diff --git a/src/lua/utils/types.lua b/src/lua/utils/types.lua index 41a3856..dfa77cb 100644 --- a/src/lua/utils/types.lua +++ b/src/lua/utils/types.lua @@ -89,6 +89,19 @@ ---@field sell integer Sell value of the card ---@field buy integer Buy price of the card (if in shop) +-- ========================================================================== +-- Schema Types +-- ========================================================================== + +---@class SchemaField +---@field type "string"|"integer"|"array"|"boolean"|"table" +---@field required boolean? +---@field items "integer"? +---@field description string + +---@class Validator +---@field validate fun(args: table, schema: table): boolean, string?, string? + -- ========================================================================== -- Endpoint Type -- ========================================================================== @@ -98,7 +111,22 @@ ---@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.Params, send_response: fun(response: table)) Execute function +---@field execute fun(args: Request.Params, send_response: fun(response: EndpointResponse)) Execute function + +-- ========================================================================== +-- Core Infrastructure Types +-- ========================================================================== + +---@class Dispatcher +---@field endpoints table +---@field Server Server? + +---@class Server +---@field host string +---@field port integer +---@field server_socket TCPSocketServer? +---@field client_socket TCPSocketClient? +---@field current_request_id integer|string|nil -- ========================================================================== -- Request Types (JSON-RPC 2.0) @@ -119,23 +147,30 @@ ---@alias Request.Params ---| Endpoint.Add.Params ---| Endpoint.Buy.Params +---| Endpoint.CashOut.Params ---| Endpoint.Discard.Params +---| Endpoint.Gamestate.Params +---| Endpoint.Health.Params ---| Endpoint.Load.Params +---| Endpoint.Menu.Params +---| Endpoint.NextRound.Params ---| Endpoint.Play.Params ---| Endpoint.Rearrange.Params +---| Endpoint.Reroll.Params ---| Endpoint.Save.Params +---| Endpoint.Select.Params ---| Endpoint.Sell.Params ---| Endpoint.Set.Params ----| Endpoint.Run.Params +---| Endpoint.Skip.Params ---| Endpoint.Use.Params ----| TestEndpoint.Echo.Params ----| TestEndpoint.Endpoint.Params ----| TestEndpoint.Error.Params ----| TestEndpoint.State.Params ----| TestEndpoint.Validation.Params +---| Endpoint.Test.Echo.Params +---| Endpoint.Test.Endpoint.Params +---| Endpoint.Test.Error.Params +---| Endpoint.Test.State.Params +---| Endpoint.Test.Validation.Params -- ========================================================================== --- Response Types (JSON-RPC 2.0) +-- Response Types -- ========================================================================== ---@class PathResponse @@ -143,9 +178,26 @@ ---@field path string Path to the file ---@class HealthResponse +---@field status "ok" + +---@alias GameStateResponse +---| GameState # Return the current game state of the game + +---@class ErrorResponse +---@field message string Human-readable error message +---@field name ErrorName Error name (BAD_REQUEST, INVALID_STATE, etc.) + +---@class TestResponse ---@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) ----@alias GameStateResponse GameState +---@alias EndpointResponse +---| HealthResponse +---| PathResponse +---| GameStateResponse +---| ErrorResponse +---| TestResponse ---@class ResponseSuccess ---@field jsonrpc "2.0" @@ -164,3 +216,43 @@ ---@class ResponseError.Error.Data ---@field name ErrorName Semantic error code + +-- ========================================================================== +-- 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 + +-- ========================================================================== +-- Settings Types +-- ========================================================================== + +---@class Settings +---@field host string +---@field port integer +---@field headless boolean +---@field fast boolean +---@field render_on_api boolean +---@field audio boolean +---@field debug boolean +---@field no_shaders boolean + +-- ========================================================================== +-- Debug Types +-- ========================================================================== + +---@class Debug +---@field log table? From 55b8f2de2d0bfb5c25f4ed8252653e7c9ab06d66 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Fri, 5 Dec 2025 17:24:30 +0100 Subject: [PATCH 179/230] test(lua.core): update test to use JSON-RPC 2.0 format --- tests/lua/core/test_dispatcher.py | 427 ++++++++++++------------------ tests/lua/core/test_server.py | 136 ++++++---- tests/lua/core/test_validator.py | 50 ++-- 3 files changed, 284 insertions(+), 329 deletions(-) diff --git a/tests/lua/core/test_dispatcher.py b/tests/lua/core/test_dispatcher.py index 957c565..0542d8e 100644 --- a/tests/lua/core/test_dispatcher.py +++ b/tests/lua/core/test_dispatcher.py @@ -1,5 +1,5 @@ """ -Integration tests for BB_DISPATCHER request routing and validation. +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 @@ -12,77 +12,95 @@ import json import socket -from tests.lua.conftest import BUFFER_SIZE +from tests.lua.conftest import BUFFER_SIZE, 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 'name' field (string) - - Request has 'arguments' field (table) + - Request has 'method' field (string) + - Request has 'params' field (optional, defaults to {}) - Endpoint exists in registry """ def test_missing_name_field(self, client: socket.socket) -> None: - """Test that requests without 'name' field are rejected.""" - request = json.dumps({"arguments": {}}) + "\n" + """Test that requests without 'method' field are rejected.""" + global _test_request_id + _test_request_id += 1 + # Manually construct malformed request (missing 'method' field) + request = ( + json.dumps({"jsonrpc": "2.0", "params": {}, "id": _test_request_id}) + "\n" + ) client.send(request.encode()) response = client.recv(BUFFER_SIZE).decode().strip() - data = json.loads(response) + parsed = json.loads(response) - assert "error" in data - assert "error_code" in data - assert data["error_code"] == "BAD_REQUEST" - assert "name" in data["error"].lower() + 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: socket.socket) -> None: - """Test that 'name' field must be a string.""" - request = json.dumps({"name": 123, "arguments": {}}) + "\n" + """Test that 'method' field must be a string.""" + global _test_request_id + _test_request_id += 1 + # Manually construct malformed request ('method' is not a string) + request = ( + json.dumps( + {"jsonrpc": "2.0", "method": 123, "params": {}, "id": _test_request_id} + ) + + "\n" + ) client.send(request.encode()) response = client.recv(BUFFER_SIZE).decode().strip() - data = json.loads(response) + parsed = json.loads(response) - assert "error" in data - assert data["error_code"] == "BAD_REQUEST" + assert "error" in parsed + assert parsed["error"]["data"]["name"] == "BAD_REQUEST" def test_missing_arguments_field(self, client: socket.socket) -> None: - """Test that requests without 'arguments' field are rejected.""" - request = json.dumps({"name": "health"}) + "\n" + """Test that requests without 'params' field succeed (params is optional in JSON-RPC 2.0).""" + global _test_request_id + _test_request_id += 1 + # Manually construct request without 'params' field + request = ( + json.dumps({"jsonrpc": "2.0", "method": "health", "id": _test_request_id}) + + "\n" + ) client.send(request.encode()) response = client.recv(BUFFER_SIZE).decode().strip() - data = json.loads(response) + parsed = json.loads(response) - assert "error" in data - assert data["error_code"] == "BAD_REQUEST" - assert "arguments" in data["error"].lower() + # 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: socket.socket) -> None: """Test that unknown endpoints are rejected.""" - request = json.dumps({"name": "nonexistent_endpoint", "arguments": {}}) + "\n" - client.send(request.encode()) + response = api(client, "nonexistent_endpoint", {}) - response = client.recv(BUFFER_SIZE).decode().strip() - data = json.loads(response) - - assert "error" in data - assert data["error_code"] == "BAD_REQUEST" - assert "nonexistent_endpoint" in data["error"] + 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: socket.socket) -> None: """Test that valid requests to health endpoint succeed.""" - request = json.dumps({"name": "health", "arguments": {}}) + "\n" - client.send(request.encode()) - - response = client.recv(BUFFER_SIZE).decode().strip() - data = json.loads(response) + response = api(client, "health", {}) # Health endpoint should return success - assert "status" in data - assert data["status"] == "ok" + assert "result" in response + assert "status" in response["result"] + assert response["result"]["status"] == "ok" class TestDispatcherSchemaValidation: @@ -95,133 +113,95 @@ class TestDispatcherSchemaValidation: def test_missing_required_field(self, client: socket.socket) -> None: """Test that missing required fields are rejected.""" # test_endpoint requires 'required_string' and 'required_integer' - request = ( - json.dumps( - { - "name": "test_endpoint", - "arguments": { - "required_integer": 50, - "required_enum": "option_a", - # Missing 'required_string' - }, - } - ) - + "\n" + response = api( + client, + "test_endpoint", + { + "required_integer": 50, + "required_enum": "option_a", + # Missing 'required_string' + }, ) - client.send(request.encode()) - - response = client.recv(BUFFER_SIZE).decode().strip() - data = json.loads(response) - assert "error" in data - assert data["error_code"] == "BAD_REQUEST" - assert "required_string" in data["error"].lower() + 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: socket.socket ) -> None: """Test that type validation rejects wrong types.""" - request = ( - json.dumps( - { - "name": "test_endpoint", - "arguments": { - "required_string": "valid_string", - "required_integer": "not_an_integer", # Should be integer - "required_enum": "option_a", - }, - } - ) - + "\n" + response = api( + client, + "test_endpoint", + { + "required_string": "valid_string", + "required_integer": "not_an_integer", # Should be integer + "required_enum": "option_a", + }, ) - client.send(request.encode()) - - response = client.recv(BUFFER_SIZE).decode().strip() - data = json.loads(response) - assert "error" in data - assert data["error_code"] == "BAD_REQUEST" - assert "required_integer" in data["error"].lower() + 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: socket.socket) -> None: """Test that array items are validated for correct type.""" - request = ( - json.dumps( - { - "name": "test_endpoint", - "arguments": { - "required_string": "test", - "required_integer": 50, - "optional_array_integers": [ - 1, - 2, - "not_integer", - 4, - ], # Should be integers - }, - } - ) - + "\n" + response = api( + client, + "test_endpoint", + { + "required_string": "test", + "required_integer": 50, + "optional_array_integers": [ + 1, + 2, + "not_integer", + 4, + ], # Should be integers + }, ) - client.send(request.encode()) - response = client.recv(BUFFER_SIZE).decode().strip() - data = json.loads(response) - - assert "error" in data - assert data["error_code"] == "BAD_REQUEST" + assert "error" in response + assert response["error"]["data"]["name"] == "BAD_REQUEST" def test_valid_request_with_all_fields(self, client: socket.socket) -> None: """Test that valid requests with multiple fields pass validation.""" - request = ( - json.dumps( - { - "name": "test_endpoint", - "arguments": { - "required_string": "test", - "required_integer": 50, - "optional_string": "optional", - "optional_integer": 42, - "optional_array_integers": [1, 2, 3], - }, - } - ) - + "\n" + response = api( + client, + "test_endpoint", + { + "required_string": "test", + "required_integer": 50, + "optional_string": "optional", + "optional_integer": 42, + "optional_array_integers": [1, 2, 3], + }, ) - client.send(request.encode()) - - response = client.recv(BUFFER_SIZE).decode().strip() - data = json.loads(response) # Should succeed and echo back - assert "success" in data - assert data["success"] is True - assert "received_args" in data + 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: socket.socket ) -> None: """Test that valid requests with only required fields pass validation.""" - request = ( - json.dumps( - { - "name": "test_endpoint", - "arguments": { - "required_string": "test", - "required_integer": 1, - "required_enum": "option_c", - }, - } - ) - + "\n" + response = api( + client, + "test_endpoint", + { + "required_string": "test", + "required_integer": 1, + "required_enum": "option_c", + }, ) - client.send(request.encode()) - response = client.recv(BUFFER_SIZE).decode().strip() - data = json.loads(response) - - assert "success" in data - assert data["success"] is True + assert "result" in response + assert "success" in response["result"] + assert response["result"]["success"] is True class TestDispatcherStateValidation: @@ -234,20 +214,17 @@ class TestDispatcherStateValidation: def test_state_validation_enforcement(self, client: socket.socket) -> None: """Test that endpoints with requires_state are validated.""" # test_state_endpoint requires SPLASH or MENU state - request = json.dumps({"name": "test_state_endpoint", "arguments": {}}) + "\n" - client.send(request.encode()) - - response = client.recv(BUFFER_SIZE).decode().strip() - data = json.loads(response) + response = api(client, "test_state_endpoint", {}) # Response depends on current game state - # Either succeeds if in correct state, or fails with STATE_INVALID_STATE - if "error" in data: - assert data["error_code"] == "INVALID_STATE" - assert "requires" in data["error"].lower() + # 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 "success" in data - assert data["state_validated"] is True + assert "result" in response + assert "success" in response["result"] + assert response["result"]["state_validated"] is True class TestDispatcherExecution: @@ -259,92 +236,44 @@ class TestDispatcherExecution: def test_successful_endpoint_execution(self, client: socket.socket) -> None: """Test that endpoints execute successfully with valid input.""" - request = ( - json.dumps( - { - "name": "test_endpoint", - "arguments": { - "required_string": "test", - "required_integer": 42, - "required_enum": "option_a", - }, - } - ) - + "\n" + response = api( + client, + "test_endpoint", + { + "required_string": "test", + "required_integer": 42, + "required_enum": "option_a", + }, ) - client.send(request.encode()) - - response = client.recv(BUFFER_SIZE).decode().strip() - data = json.loads(response) - assert "success" in data - assert data["success"] is True - assert "received_args" in data - assert data["received_args"]["required_integer"] == 42 + 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: socket.socket) -> None: """Test that runtime errors are caught and returned properly.""" - request = ( - json.dumps( - { - "name": "test_error_endpoint", - "arguments": { - "error_type": "throw_error", - }, - } - ) - + "\n" - ) - client.send(request.encode()) - - response = client.recv(BUFFER_SIZE).decode().strip() - data = json.loads(response) + response = api(client, "test_error_endpoint", {"error_type": "throw_error"}) - assert "error" in data - assert data["error_code"] == "INTERNAL_ERROR" - assert "Intentional test error" in data["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: socket.socket) -> None: - """Test that all execution errors use EXEC_INTERNAL_ERROR.""" - request = ( - json.dumps( - { - "name": "test_error_endpoint", - "arguments": { - "error_type": "throw_error", - }, - } - ) - + "\n" - ) - client.send(request.encode()) + """Test that all execution errors use INTERNAL_ERROR.""" + response = api(client, "test_error_endpoint", {"error_type": "throw_error"}) - response = client.recv(BUFFER_SIZE).decode().strip() - data = json.loads(response) - - # Should always be EXEC_INTERNAL_ERROR (no categorization) - assert data["error_code"] == "INTERNAL_ERROR" + # Should always be INTERNAL_ERROR (no categorization) + assert response["error"]["data"]["name"] == "INTERNAL_ERROR" def test_execution_success_when_no_error(self, client: socket.socket) -> None: """Test that endpoints can execute successfully.""" - request = ( - json.dumps( - { - "name": "test_error_endpoint", - "arguments": { - "error_type": "success", - }, - } - ) - + "\n" - ) - client.send(request.encode()) - - response = client.recv(BUFFER_SIZE).decode().strip() - data = json.loads(response) + response = api(client, "test_error_endpoint", {"error_type": "success"}) - assert "success" in data - assert data["success"] is True + assert "result" in response + assert "success" in response["result"] + assert response["result"]["success"] is True class TestDispatcherEndpointRegistry: @@ -352,53 +281,39 @@ class TestDispatcherEndpointRegistry: def test_health_endpoint_is_registered(self, client: socket.socket) -> None: """Test that the health endpoint is properly registered.""" - request = json.dumps({"name": "health", "arguments": {}}) + "\n" - client.send(request.encode()) + response = api(client, "health", {}) - response = client.recv(BUFFER_SIZE).decode().strip() - data = json.loads(response) - - assert "status" in data - assert data["status"] == "ok" + assert "result" in response + assert "status" in response["result"] + assert response["result"]["status"] == "ok" def test_multiple_sequential_requests_to_same_endpoint( self, client: socket.socket ) -> None: """Test that multiple requests to the same endpoint work.""" for i in range(3): - request = json.dumps({"name": "health", "arguments": {}}) + "\n" - client.send(request.encode()) - - response = client.recv(BUFFER_SIZE).decode().strip() - data = json.loads(response) + response = api(client, "health", {}) - assert "status" in data - assert data["status"] == "ok" + assert "result" in response + assert "status" in response["result"] + assert response["result"]["status"] == "ok" def test_requests_to_different_endpoints(self, client: socket.socket) -> None: """Test that requests can be routed to different endpoints.""" # Request to health endpoint - request1 = json.dumps({"name": "health", "arguments": {}}) + "\n" - client.send(request1.encode()) - response1 = client.recv(BUFFER_SIZE).decode().strip() - data1 = json.loads(response1) - assert "status" in data1 + response1 = api(client, "health", {}) + assert "result" in response1 + assert "status" in response1["result"] # Request to test_endpoint - request2 = ( - json.dumps( - { - "name": "test_endpoint", - "arguments": { - "required_string": "test", - "required_integer": 25, - "required_enum": "option_a", - }, - } - ) - + "\n" + response2 = api( + client, + "test_endpoint", + { + "required_string": "test", + "required_integer": 25, + "required_enum": "option_a", + }, ) - client.send(request2.encode()) - response2 = client.recv(BUFFER_SIZE).decode().strip() - data2 = json.loads(response2) - assert "success" in data2 + 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 index d6c150d..77a1e2c 100644 --- a/tests/lua/core/test_server.py +++ b/tests/lua/core/test_server.py @@ -1,5 +1,5 @@ """ -Integration tests for BB_SERVER TCP communication. +Integration tests for BB_SERVER TCP communication (JSON-RPC 2.0). Test classes are organized by BB_SERVER function: - TestBBServerInit: BB_SERVER.init() - server initialization and port binding @@ -17,6 +17,26 @@ from tests.lua.conftest import BUFFER_SIZE +# Request ID counter for JSON-RPC 2.0 +_test_request_id = 0 + + +def make_request(method: str, params: dict = {}) -> str: + """Create a JSON-RPC 2.0 request string.""" + global _test_request_id + _test_request_id += 1 + return ( + json.dumps( + { + "jsonrpc": "2.0", + "method": method, + "params": params, + "id": _test_request_id, + } + ) + + "\n" + ) + class TestBBServerInit: """Tests for BB_SERVER.init() - server initialization and port binding.""" @@ -112,8 +132,8 @@ def test_reconnect_after_graceful_disconnect(self, port: int) -> None: sock1.settimeout(2) sock1.connect(("127.0.0.1", port)) - # Send a request - msg = json.dumps({"name": "health", "arguments": {}}) + "\n" + # Send a JSON-RPC 2.0 request + msg = make_request("health", {}) sock1.send(msg.encode()) sock1.recv(BUFFER_SIZE) # Consume response @@ -128,7 +148,7 @@ def test_reconnect_after_graceful_disconnect(self, port: int) -> None: assert sock2.fileno() != -1, "Should reconnect successfully" # Verify new connection works - sock2.send(msg.encode()) + sock2.send(make_request("health", {}).encode()) response = sock2.recv(BUFFER_SIZE) assert len(response) > 0, "Should receive response after reconnect" finally: @@ -167,26 +187,29 @@ class TestBBServerReceive: def test_message_too_large(self, client: socket.socket) -> None: """Test that messages exceeding 256 bytes are rejected.""" # Create message > 255 bytes (line + newline must be <= 256) - large_msg = {"name": "test", "data": "x" * 300} + large_msg = { + "jsonrpc": "2.0", + "method": "test", + "params": {"data": "x" * 300}, + "id": 1, + } msg = json.dumps(large_msg) + "\n" assert len(msg) > 256, "Test message should exceed 256 bytes" client.send(msg.encode()) - # Give game loop time to process and respond - response = client.recv(BUFFER_SIZE).decode().strip() - data = json.loads(response) + raw_data = json.loads(response) - assert "error" in data - assert "error_code" in data - assert data["error_code"] == "BAD_REQUEST" - assert "too large" in data["error"].lower() + # Response is JSON-RPC 2.0 error format + assert "error" in raw_data + assert raw_data["error"]["data"]["name"] == "BAD_REQUEST" + assert "too large" in raw_data["error"]["message"].lower() def test_pipelined_messages_rejected(self, client: socket.socket) -> None: """Test that sending multiple messages at once are processed sequentially.""" - msg1 = json.dumps({"name": "health", "arguments": {}}) + "\n" - msg2 = json.dumps({"name": "health", "arguments": {}}) + "\n" + msg1 = make_request("health", {}) + msg2 = make_request("health", {}) # Send both messages in one packet (pipelining) client.send((msg1 + msg2).encode()) @@ -197,61 +220,59 @@ def test_pipelined_messages_rejected(self, client: socket.socket) -> None: # We may get one or both responses depending on timing # The important thing is no error occurred lines = response.split("\n") - data1 = json.loads(lines[0]) + raw_data1 = json.loads(lines[0]) # First response should be successful - assert "status" in data1 - assert data1["status"] == "ok" + assert "result" in raw_data1 + assert "status" in raw_data1["result"] + assert raw_data1["result"]["status"] == "ok" # If we got both in one recv, verify second is also good if len(lines) > 1 and lines[1]: - data2 = json.loads(lines[1]) - assert "status" in data2 - assert data2["status"] == "ok" + raw_data2 = json.loads(lines[1]) + assert "result" in raw_data2 + assert "status" in raw_data2["result"] + assert raw_data2["result"]["status"] == "ok" def test_invalid_json_syntax(self, client: socket.socket) -> None: """Test that malformed JSON is rejected.""" client.send(b"{invalid json}\n") response = client.recv(BUFFER_SIZE).decode().strip() - data = json.loads(response) + raw_data = json.loads(response) - assert "error" in data - assert "error_code" in data - assert data["error_code"] == "BAD_REQUEST" + assert "error" in raw_data + assert raw_data["error"]["data"]["name"] == "BAD_REQUEST" def test_json_string_rejected(self, client: socket.socket) -> None: """Test that JSON strings are rejected (must be object).""" client.send(b'"just a string"\n') response = client.recv(BUFFER_SIZE).decode().strip() - data = json.loads(response) + raw_data = json.loads(response) - assert "error" in data - assert "error_code" in data - assert data["error_code"] == "BAD_REQUEST" + assert "error" in raw_data + assert raw_data["error"]["data"]["name"] == "BAD_REQUEST" def test_json_number_rejected(self, client: socket.socket) -> None: """Test that JSON numbers are rejected (must be object).""" client.send(b"42\n") response = client.recv(BUFFER_SIZE).decode().strip() - data = json.loads(response) + raw_data = json.loads(response) - assert "error" in data - assert "error_code" in data - assert data["error_code"] == "BAD_REQUEST" + assert "error" in raw_data + assert raw_data["error"]["data"]["name"] == "BAD_REQUEST" def test_json_array_rejected(self, client: socket.socket) -> None: """Test that JSON arrays are rejected (must be object starting with '{').""" client.send(b'["array", "of", "values"]\n') response = client.recv(BUFFER_SIZE).decode().strip() - data = json.loads(response) + raw_data = json.loads(response) - assert "error" in data - assert "error_code" in data - assert data["error_code"] == "BAD_REQUEST" + assert "error" in raw_data + assert raw_data["error"]["data"]["name"] == "BAD_REQUEST" def test_only_whitespace_line_rejected(self, client: socket.socket) -> None: """Test that whitespace-only lines are rejected as invalid JSON.""" @@ -259,11 +280,11 @@ def test_only_whitespace_line_rejected(self, client: socket.socket) -> None: client.send(b" \t \n") response = client.recv(BUFFER_SIZE).decode().strip() - data = json.loads(response) + raw_data = json.loads(response) # Should be rejected as invalid JSON (trimmed to empty, doesn't start with '{') - assert "error" in data - assert data["error_code"] == "BAD_REQUEST" + assert "error" in raw_data + assert raw_data["error"]["data"]["name"] == "BAD_REQUEST" class TestBBServerSendResponse: @@ -278,27 +299,46 @@ def test_server_accepts_data(self, client: socket.socket) -> None: def test_multiple_sequential_valid_requests(self, client: socket.socket) -> None: """Test handling multiple valid requests sent sequentially (not pipelined).""" # Send first request - msg1 = json.dumps({"name": "health", "arguments": {}}) + "\n" + msg1 = make_request("health", {}) client.send(msg1.encode()) response1 = client.recv(BUFFER_SIZE).decode().strip() - data1 = json.loads(response1) - assert "status" in data1 # Health endpoint returns status + raw_data1 = json.loads(response1) + assert "result" in raw_data1 + assert "status" in raw_data1["result"] # Health endpoint returns status # Send second request on same connection - msg2 = json.dumps({"name": "health", "arguments": {}}) + "\n" + msg2 = make_request("health", {}) client.send(msg2.encode()) response2 = client.recv(BUFFER_SIZE).decode().strip() - data2 = json.loads(response2) - assert "status" in data2 + raw_data2 = json.loads(response2) + assert "result" in raw_data2 + assert "status" in raw_data2["result"] def test_whitespace_around_json_accepted(self, client: socket.socket) -> None: """Test that JSON with leading/trailing whitespace is accepted.""" - msg = " " + json.dumps({"name": "health", "arguments": {}}) + " \n" + global _test_request_id + _test_request_id += 1 + msg = ( + " " + + json.dumps( + { + "jsonrpc": "2.0", + "method": "health", + "params": {}, + "id": _test_request_id, + } + ) + + " \n" + ) client.send(msg.encode()) response = client.recv(BUFFER_SIZE).decode().strip() - data = json.loads(response) + raw_data = json.loads(response) # Should be processed successfully (whitespace trimmed at line 134) - assert "status" in data or "error" in data + # Result should contain health status or error + if "result" in raw_data: + assert "status" in raw_data["result"] + else: + assert "error" in raw_data diff --git a/tests/lua/core/test_validator.py b/tests/lua/core/test_validator.py index 8bc93c6..07a832a 100644 --- a/tests/lua/core/test_validator.py +++ b/tests/lua/core/test_validator.py @@ -47,8 +47,8 @@ def test_invalid_string_type(self, client: socket.socket) -> None: ) assert_error_response( response, - expected_error_code="BAD_REQUEST", - expected_message_contains="string_field", + "BAD_REQUEST", + "string_field", ) def test_valid_integer_type(self, client: socket.socket) -> None: @@ -75,8 +75,8 @@ def test_invalid_integer_type_float(self, client: socket.socket) -> None: ) assert_error_response( response, - expected_error_code="BAD_REQUEST", - expected_message_contains="integer_field", + "BAD_REQUEST", + "integer_field", ) def test_invalid_integer_type_string(self, client: socket.socket) -> None: @@ -91,8 +91,8 @@ def test_invalid_integer_type_string(self, client: socket.socket) -> None: ) assert_error_response( response, - expected_error_code="BAD_REQUEST", - expected_message_contains="integer_field", + "BAD_REQUEST", + "integer_field", ) def test_valid_array_type(self, client: socket.socket) -> None: @@ -119,8 +119,8 @@ def test_invalid_array_type_not_sequential(self, client: socket.socket) -> None: ) assert_error_response( response, - expected_error_code="BAD_REQUEST", - expected_message_contains="array_field", + "BAD_REQUEST", + "array_field", ) def test_invalid_array_type_string(self, client: socket.socket) -> None: @@ -135,8 +135,8 @@ def test_invalid_array_type_string(self, client: socket.socket) -> None: ) assert_error_response( response, - expected_error_code="BAD_REQUEST", - expected_message_contains="array_field", + "BAD_REQUEST", + "array_field", ) def test_valid_boolean_type_true(self, client: socket.socket) -> None: @@ -175,8 +175,8 @@ def test_invalid_boolean_type_string(self, client: socket.socket) -> None: ) assert_error_response( response, - expected_error_code="BAD_REQUEST", - expected_message_contains="boolean_field", + "BAD_REQUEST", + "boolean_field", ) def test_invalid_boolean_type_number(self, client: socket.socket) -> None: @@ -191,8 +191,8 @@ def test_invalid_boolean_type_number(self, client: socket.socket) -> None: ) assert_error_response( response, - expected_error_code="BAD_REQUEST", - expected_message_contains="boolean_field", + "BAD_REQUEST", + "boolean_field", ) def test_valid_table_type(self, client: socket.socket) -> None: @@ -231,8 +231,8 @@ def test_invalid_table_type_array(self, client: socket.socket) -> None: ) assert_error_response( response, - expected_error_code="BAD_REQUEST", - expected_message_contains="table_field", + "BAD_REQUEST", + "table_field", ) def test_invalid_table_type_string(self, client: socket.socket) -> None: @@ -247,8 +247,8 @@ def test_invalid_table_type_string(self, client: socket.socket) -> None: ) assert_error_response( response, - expected_error_code="BAD_REQUEST", - expected_message_contains="table_field", + "BAD_REQUEST", + "table_field", ) @@ -278,8 +278,8 @@ def test_required_field_missing(self, client: socket.socket) -> None: ) assert_error_response( response, - expected_error_code="BAD_REQUEST", - expected_message_contains="required_field", + "BAD_REQUEST", + "required_field", ) def test_optional_field_missing(self, client: socket.socket) -> None: @@ -327,8 +327,8 @@ def test_array_of_integers_invalid_float(self, client: socket.socket) -> None: ) assert_error_response( response, - expected_error_code="BAD_REQUEST", - expected_message_contains="array_of_integers", + "BAD_REQUEST", + "array_of_integers", ) def test_array_of_integers_invalid_string(self, client: socket.socket) -> None: @@ -343,8 +343,8 @@ def test_array_of_integers_invalid_string(self, client: socket.socket) -> None: ) assert_error_response( response, - expected_error_code="BAD_REQUEST", - expected_message_contains="array_of_integers", + "BAD_REQUEST", + "array_of_integers", ) @@ -371,7 +371,7 @@ def test_multiple_errors_returns_first(self, client: socket.socket) -> None: # 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_code"] in [ + assert response["error"]["data"]["name"] in [ "BAD_REQUEST", "BAD_REQUEST", ] From 96cb272f9a188b0a2b97d403b79fd49ebef99e19 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Fri, 5 Dec 2025 17:24:46 +0100 Subject: [PATCH 180/230] test(lua.endpoints): update test for JSON-RPC 2.0 response format --- tests/lua/endpoints/test_add.py | 98 ++++++++++++++------------ tests/lua/endpoints/test_buy.py | 12 ++-- tests/lua/endpoints/test_cash_out.py | 12 ++-- tests/lua/endpoints/test_discard.py | 8 +-- tests/lua/endpoints/test_gamestate.py | 34 ++++----- tests/lua/endpoints/test_health.py | 7 +- tests/lua/endpoints/test_load.py | 2 +- tests/lua/endpoints/test_menu.py | 6 +- tests/lua/endpoints/test_next_round.py | 10 +-- tests/lua/endpoints/test_play.py | 12 ++-- tests/lua/endpoints/test_rearrange.py | 6 +- tests/lua/endpoints/test_reroll.py | 3 +- tests/lua/endpoints/test_save.py | 2 +- tests/lua/endpoints/test_select.py | 14 ++-- tests/lua/endpoints/test_sell.py | 20 +++--- tests/lua/endpoints/test_set.py | 18 ++--- tests/lua/endpoints/test_skip.py | 20 +++--- tests/lua/endpoints/test_start.py | 16 ++--- tests/lua/endpoints/test_use.py | 28 +++++--- 19 files changed, 176 insertions(+), 152 deletions(-) diff --git a/tests/lua/endpoints/test_add.py b/tests/lua/endpoints/test_add.py index 2164547..1f54200 100644 --- a/tests/lua/endpoints/test_add.py +++ b/tests/lua/endpoints/test_add.py @@ -20,8 +20,8 @@ def test_add_joker(self, client: socket.socket) -> None: assert gamestate["state"] == "SELECTING_HAND" assert gamestate["jokers"]["count"] == 0 response = api(client, "add", {"key": "j_joker"}) - assert response["jokers"]["count"] == 1 - assert response["jokers"]["cards"][0]["key"] == "j_joker" + assert response["result"]["jokers"]["count"] == 1 + assert response["result"]["jokers"]["cards"][0]["key"] == "j_joker" def test_add_consumable_tarot(self, client: socket.socket) -> None: """Test adding a tarot consumable with valid key.""" @@ -33,8 +33,8 @@ def test_add_consumable_tarot(self, client: socket.socket) -> None: assert gamestate["state"] == "SELECTING_HAND" assert gamestate["consumables"]["count"] == 0 response = api(client, "add", {"key": "c_fool"}) - assert response["consumables"]["count"] == 1 - assert response["consumables"]["cards"][0]["key"] == "c_fool" + assert response["result"]["consumables"]["count"] == 1 + assert response["result"]["consumables"]["cards"][0]["key"] == "c_fool" def test_add_consumable_planet(self, client: socket.socket) -> None: """Test adding a planet consumable with valid key.""" @@ -46,8 +46,8 @@ def test_add_consumable_planet(self, client: socket.socket) -> None: assert gamestate["state"] == "SELECTING_HAND" assert gamestate["consumables"]["count"] == 0 response = api(client, "add", {"key": "c_mercury"}) - assert response["consumables"]["count"] == 1 - assert response["consumables"]["cards"][0]["key"] == "c_mercury" + assert response["result"]["consumables"]["count"] == 1 + assert response["result"]["consumables"]["cards"][0]["key"] == "c_mercury" def test_add_consumable_spectral(self, client: socket.socket) -> None: """Test adding a spectral consumable with valid key.""" @@ -59,8 +59,8 @@ def test_add_consumable_spectral(self, client: socket.socket) -> None: assert gamestate["state"] == "SELECTING_HAND" assert gamestate["consumables"]["count"] == 0 response = api(client, "add", {"key": "c_familiar"}) - assert response["consumables"]["count"] == 1 - assert response["consumables"]["cards"][0]["key"] == "c_familiar" + assert response["result"]["consumables"]["count"] == 1 + assert response["result"]["consumables"]["cards"][0]["key"] == "c_familiar" def test_add_voucher(self, client: socket.socket) -> None: """Test adding a voucher with valid key in SHOP state.""" @@ -72,8 +72,8 @@ def test_add_voucher(self, client: socket.socket) -> None: assert gamestate["state"] == "SHOP" assert gamestate["vouchers"]["count"] == 0 response = api(client, "add", {"key": "v_overstock_norm"}) - assert response["vouchers"]["count"] == 1 - assert response["vouchers"]["cards"][0]["key"] == "v_overstock_norm" + assert response["result"]["vouchers"]["count"] == 1 + assert response["result"]["vouchers"]["cards"][0]["key"] == "v_overstock_norm" def test_add_playing_card(self, client: socket.socket) -> None: """Test adding a playing card with valid key.""" @@ -85,8 +85,8 @@ def test_add_playing_card(self, client: socket.socket) -> None: assert gamestate["state"] == "SELECTING_HAND" assert gamestate["hand"]["count"] == 8 response = api(client, "add", {"key": "H_A"}) - assert response["hand"]["count"] == 9 - assert response["hand"]["cards"][8]["key"] == "H_A" + assert response["result"]["hand"]["count"] == 9 + assert response["result"]["hand"]["cards"][8]["key"] == "H_A" def test_add_no_key_provided(self, client: socket.socket) -> None: """Test add endpoint with no key parameter.""" @@ -205,9 +205,9 @@ def test_add_playing_card_with_seal(self, client: socket.socket, seal: str) -> N assert gamestate["state"] == "SELECTING_HAND" assert gamestate["hand"]["count"] == 8 response = api(client, "add", {"key": "H_A", "seal": seal}) - assert response["hand"]["count"] == 9 - assert response["hand"]["cards"][8]["key"] == "H_A" - assert response["hand"]["cards"][8]["modifier"]["seal"] == seal + assert response["result"]["hand"]["count"] == 9 + assert response["result"]["hand"]["cards"][8]["key"] == "H_A" + assert response["result"]["hand"]["cards"][8]["modifier"]["seal"] == seal def test_add_playing_card_invalid_seal(self, client: socket.socket) -> None: """Test adding a playing card with invalid seal value.""" @@ -258,9 +258,11 @@ def test_add_joker_with_edition(self, client: socket.socket, edition: str) -> No assert gamestate["state"] == "SHOP" assert gamestate["jokers"]["count"] == 0 response = api(client, "add", {"key": "j_joker", "edition": edition}) - assert response["jokers"]["count"] == 1 - assert response["jokers"]["cards"][0]["key"] == "j_joker" - assert response["jokers"]["cards"][0]["modifier"]["edition"] == edition + assert response["result"]["jokers"]["count"] == 1 + assert response["result"]["jokers"]["cards"][0]["key"] == "j_joker" + assert ( + response["result"]["jokers"]["cards"][0]["modifier"]["edition"] == edition + ) @pytest.mark.parametrize("edition", ["HOLO", "FOIL", "POLYCHROME", "NEGATIVE"]) def test_add_playing_card_with_edition( @@ -275,9 +277,9 @@ def test_add_playing_card_with_edition( assert gamestate["state"] == "SELECTING_HAND" assert gamestate["hand"]["count"] == 8 response = api(client, "add", {"key": "H_A", "edition": edition}) - assert response["hand"]["count"] == 9 - assert response["hand"]["cards"][8]["key"] == "H_A" - assert response["hand"]["cards"][8]["modifier"]["edition"] == edition + assert response["result"]["hand"]["count"] == 9 + assert response["result"]["hand"]["cards"][8]["key"] == "H_A" + assert response["result"]["hand"]["cards"][8]["modifier"]["edition"] == edition def test_add_consumable_with_negative_edition(self, client: socket.socket) -> None: """Test adding a consumable with NEGATIVE edition (only valid edition for consumables).""" @@ -289,9 +291,12 @@ def test_add_consumable_with_negative_edition(self, client: socket.socket) -> No assert gamestate["state"] == "SHOP" assert gamestate["consumables"]["count"] == 0 response = api(client, "add", {"key": "c_fool", "edition": "NEGATIVE"}) - assert response["consumables"]["count"] == 1 - assert response["consumables"]["cards"][0]["key"] == "c_fool" - assert response["consumables"]["cards"][0]["modifier"]["edition"] == "NEGATIVE" + assert response["result"]["consumables"]["count"] == 1 + assert response["result"]["consumables"]["cards"][0]["key"] == "c_fool" + assert ( + response["result"]["consumables"]["cards"][0]["modifier"]["edition"] + == "NEGATIVE" + ) @pytest.mark.parametrize("edition", ["HOLO", "FOIL", "POLYCHROME"]) def test_add_consumable_with_non_negative_edition_fails( @@ -362,9 +367,12 @@ def test_add_playing_card_with_enhancement( assert gamestate["state"] == "SELECTING_HAND" assert gamestate["hand"]["count"] == 8 response = api(client, "add", {"key": "H_A", "enhancement": enhancement}) - assert response["hand"]["count"] == 9 - assert response["hand"]["cards"][8]["key"] == "H_A" - assert response["hand"]["cards"][8]["modifier"]["enhancement"] == enhancement + assert response["result"]["hand"]["count"] == 9 + assert response["result"]["hand"]["cards"][8]["key"] == "H_A" + assert ( + response["result"]["hand"]["cards"][8]["modifier"]["enhancement"] + == enhancement + ) def test_add_playing_card_invalid_enhancement(self, client: socket.socket) -> None: """Test adding a playing card with invalid enhancement value.""" @@ -415,9 +423,9 @@ def test_add_joker_with_eternal(self, client: socket.socket) -> None: assert gamestate["state"] == "SHOP" assert gamestate["jokers"]["count"] == 0 response = api(client, "add", {"key": "j_joker", "eternal": True}) - assert response["jokers"]["count"] == 1 - assert response["jokers"]["cards"][0]["key"] == "j_joker" - assert response["jokers"]["cards"][0]["modifier"]["eternal"] is True + assert response["result"]["jokers"]["count"] == 1 + assert response["result"]["jokers"]["cards"][0]["key"] == "j_joker" + assert response["result"]["jokers"]["cards"][0]["modifier"]["eternal"] is True @pytest.mark.parametrize("key", ["c_fool", "v_overstock_norm"]) def test_add_non_joker_with_eternal_fails( @@ -465,9 +473,11 @@ def test_add_joker_with_perishable( assert gamestate["state"] == "SHOP" assert gamestate["jokers"]["count"] == 0 response = api(client, "add", {"key": "j_joker", "perishable": rounds}) - assert response["jokers"]["count"] == 1 - assert response["jokers"]["cards"][0]["key"] == "j_joker" - assert response["jokers"]["cards"][0]["modifier"]["perishable"] == rounds + assert response["result"]["jokers"]["count"] == 1 + assert response["result"]["jokers"]["cards"][0]["key"] == "j_joker" + assert ( + response["result"]["jokers"]["cards"][0]["modifier"]["perishable"] == rounds + ) def test_add_joker_with_eternal_and_perishable(self, client: socket.socket) -> None: """Test adding a joker with both eternal and perishable stickers.""" @@ -481,10 +491,10 @@ def test_add_joker_with_eternal_and_perishable(self, client: socket.socket) -> N response = api( client, "add", {"key": "j_joker", "eternal": True, "perishable": 5} ) - assert response["jokers"]["count"] == 1 - assert response["jokers"]["cards"][0]["key"] == "j_joker" - assert response["jokers"]["cards"][0]["modifier"]["eternal"] is True - assert response["jokers"]["cards"][0]["modifier"]["perishable"] == 5 + assert response["result"]["jokers"]["count"] == 1 + assert response["result"]["jokers"]["cards"][0]["key"] == "j_joker" + assert response["result"]["jokers"]["cards"][0]["modifier"]["eternal"] is True + assert response["result"]["jokers"]["cards"][0]["modifier"]["perishable"] == 5 @pytest.mark.parametrize("invalid_value", [0, -1]) def test_add_joker_with_perishable_invalid_integer_fails( @@ -570,9 +580,9 @@ def test_add_joker_with_rental(self, client: socket.socket) -> None: assert gamestate["state"] == "SHOP" assert gamestate["jokers"]["count"] == 0 response = api(client, "add", {"key": "j_joker", "rental": True}) - assert response["jokers"]["count"] == 1 - assert response["jokers"]["cards"][0]["key"] == "j_joker" - assert response["jokers"]["cards"][0]["modifier"]["rental"] is True + assert response["result"]["jokers"]["count"] == 1 + assert response["result"]["jokers"]["cards"][0]["key"] == "j_joker" + assert response["result"]["jokers"]["cards"][0]["modifier"]["rental"] is True @pytest.mark.parametrize("key", ["c_fool", "v_overstock_norm"]) def test_add_non_joker_with_rental_fails( @@ -604,10 +614,10 @@ def test_add_joker_with_rental_and_eternal(self, client: socket.socket) -> None: response = api( client, "add", {"key": "j_joker", "rental": True, "eternal": True} ) - assert response["jokers"]["count"] == 1 - assert response["jokers"]["cards"][0]["key"] == "j_joker" - assert response["jokers"]["cards"][0]["modifier"]["rental"] is True - assert response["jokers"]["cards"][0]["modifier"]["eternal"] is True + assert response["result"]["jokers"]["count"] == 1 + assert response["result"]["jokers"]["cards"][0]["key"] == "j_joker" + assert response["result"]["jokers"]["cards"][0]["modifier"]["rental"] is True + assert response["result"]["jokers"]["cards"][0]["modifier"]["eternal"] is True def test_add_playing_card_with_rental_fails(self, client: socket.socket) -> None: """Test that rental cannot be applied to playing cards.""" diff --git a/tests/lua/endpoints/test_buy.py b/tests/lua/endpoints/test_buy.py index 3ebf1ae..d4f0335 100644 --- a/tests/lua/endpoints/test_buy.py +++ b/tests/lua/endpoints/test_buy.py @@ -128,7 +128,7 @@ def test_buy_joker_success(self, client: socket.socket) -> None: assert gamestate["state"] == "SHOP" assert gamestate["shop"]["cards"][0]["set"] == "JOKER" response = api(client, "buy", {"card": 0}) - assert response["jokers"]["cards"][0]["set"] == "JOKER" + assert response["result"]["jokers"]["cards"][0]["set"] == "JOKER" def test_buy_consumable_success(self, client: socket.socket) -> None: """Test buying a consumable card (Planet/Tarot/Spectral) from shop.""" @@ -136,7 +136,7 @@ def test_buy_consumable_success(self, client: socket.socket) -> None: assert gamestate["state"] == "SHOP" assert gamestate["shop"]["cards"][1]["set"] == "PLANET" response = api(client, "buy", {"card": 1}) - assert response["consumables"]["cards"][0]["set"] == "PLANET" + assert response["result"]["consumables"]["cards"][0]["set"] == "PLANET" def test_buy_voucher_success(self, client: socket.socket) -> None: """Test buying a voucher from shop.""" @@ -146,8 +146,8 @@ def test_buy_voucher_success(self, client: socket.socket) -> None: assert gamestate["state"] == "SHOP" assert gamestate["vouchers"]["cards"][0]["set"] == "VOUCHER" response = api(client, "buy", {"voucher": 0}) - assert response["used_vouchers"] is not None - assert len(response["used_vouchers"]) > 0 + assert response["result"]["used_vouchers"] is not None + assert len(response["result"]["used_vouchers"]) > 0 def test_buy_packs_success(self, client: socket.socket) -> None: """Test buying a pack from shop.""" @@ -160,8 +160,8 @@ def test_buy_packs_success(self, client: socket.socket) -> None: assert gamestate["packs"]["cards"][0]["label"] == "Buffoon Pack" assert gamestate["packs"]["cards"][1]["label"] == "Standard Pack" response = api(client, "buy", {"pack": 0}) - assert response["pack"] is not None - assert len(response["pack"]["cards"]) > 0 + assert response["result"]["pack"] is not None + assert len(response["result"]["pack"]["cards"]) > 0 class TestBuyEndpointValidation: diff --git a/tests/lua/endpoints/test_cash_out.py b/tests/lua/endpoints/test_cash_out.py index 376d0c2..f4397e7 100644 --- a/tests/lua/endpoints/test_cash_out.py +++ b/tests/lua/endpoints/test_cash_out.py @@ -9,13 +9,13 @@ def verify_cash_out_response(response: dict[str, Any]) -> None: """Verify that cash_out response has expected fields.""" # Verify state field - should transition to SHOP after cashing out - assert "state" in response - assert isinstance(response["state"], str) - assert response["state"] == "SHOP" + assert "state" in response["result"] + assert isinstance(response["result"]["state"], str) + assert response["result"]["state"] == "SHOP" # Verify shop field exists - assert "shop" in response - assert isinstance(response["shop"], dict) + assert "shop" in response["result"] + assert isinstance(response["result"]["shop"], dict) class TestCashOutEndpoint: @@ -27,7 +27,7 @@ def test_cash_out_from_ROUND_EVAL(self, client: socket.socket) -> None: assert gamestate["state"] == "ROUND_EVAL" response = api(client, "cash_out", {}) verify_cash_out_response(response) - assert response["state"] == "SHOP" + assert response["result"]["state"] == "SHOP" class TestCashOutEndpointStateRequirements: diff --git a/tests/lua/endpoints/test_discard.py b/tests/lua/endpoints/test_discard.py index e653741..eb8137c 100644 --- a/tests/lua/endpoints/test_discard.py +++ b/tests/lua/endpoints/test_discard.py @@ -56,9 +56,9 @@ def test_discard_valid_single_card(self, client: socket.socket) -> None: gamestate = load_fixture(client, "discard", "state-SELECTING_HAND") assert gamestate["state"] == "SELECTING_HAND" response = api(client, "discard", {"cards": [0]}) - assert response["state"] == "SELECTING_HAND" + assert response["result"]["state"] == "SELECTING_HAND" assert ( - response["round"]["discards_left"] + response["result"]["round"]["discards_left"] == gamestate["round"]["discards_left"] - 1 ) @@ -67,9 +67,9 @@ def test_discard_valid_multiple_cards(self, client: socket.socket) -> None: gamestate = load_fixture(client, "discard", "state-SELECTING_HAND") assert gamestate["state"] == "SELECTING_HAND" response = api(client, "discard", {"cards": [1, 2, 3]}) - assert response["state"] == "SELECTING_HAND" + assert response["result"]["state"] == "SELECTING_HAND" assert ( - response["round"]["discards_left"] + response["result"]["round"]["discards_left"] == gamestate["round"]["discards_left"] - 1 ) diff --git a/tests/lua/endpoints/test_gamestate.py b/tests/lua/endpoints/test_gamestate.py index 9b1d775..581bcb7 100644 --- a/tests/lua/endpoints/test_gamestate.py +++ b/tests/lua/endpoints/test_gamestate.py @@ -9,24 +9,24 @@ def verify_base_gamestate_response(response: dict[str, Any]) -> None: """Verify that gamestate response has all base fields.""" # Verify state field - assert "state" in response - assert isinstance(response["state"], str) - assert len(response["state"]) > 0 + assert "state" in response["result"] + assert isinstance(response["result"]["state"], str) + assert len(response["result"]["state"]) > 0 # Verify round_num field - assert "round_num" in response - assert isinstance(response["round_num"], int) - assert response["round_num"] >= 0 + assert "round_num" in response["result"] + assert isinstance(response["result"]["round_num"], int) + assert response["result"]["round_num"] >= 0 # Verify ante_num field - assert "ante_num" in response - assert isinstance(response["ante_num"], int) - assert response["ante_num"] >= 0 + assert "ante_num" in response["result"] + assert isinstance(response["result"]["ante_num"], int) + assert response["result"]["ante_num"] >= 0 # Verify money field - assert "money" in response - assert isinstance(response["money"], int) - assert response["money"] >= 0 + assert "money" in response["result"] + assert isinstance(response["result"]["money"], int) + assert response["result"]["money"] >= 0 class TestGamestateEndpoint: @@ -37,7 +37,7 @@ def test_gamestate_from_MENU(self, client: socket.socket) -> None: api(client, "menu", {}) response = api(client, "gamestate", {}) verify_base_gamestate_response(response) - assert response["state"] == "MENU" + assert response["result"]["state"] == "MENU" def test_gamestate_from_BLIND_SELECT(self, client: socket.socket) -> None: """Test that gamestate from BLIND_SELECT state is valid.""" @@ -49,7 +49,7 @@ def test_gamestate_from_BLIND_SELECT(self, client: socket.socket) -> None: assert gamestate["stake"] == "WHITE" response = api(client, "gamestate", {}) verify_base_gamestate_response(response) - assert response["state"] == "BLIND_SELECT" - assert response["round_num"] == 0 - assert response["deck"] == "RED" - assert response["stake"] == "WHITE" + assert response["result"]["state"] == "BLIND_SELECT" + assert response["result"]["round_num"] == 0 + assert response["result"]["deck"] == "RED" + assert response["result"]["stake"] == "WHITE" diff --git a/tests/lua/endpoints/test_health.py b/tests/lua/endpoints/test_health.py index 70ba4a4..c156eae 100644 --- a/tests/lua/endpoints/test_health.py +++ b/tests/lua/endpoints/test_health.py @@ -12,8 +12,9 @@ def assert_health_response(response: dict[str, Any]) -> None: - assert "status" in response - assert response["status"] == "ok" + assert "result" in response + assert "status" in response["result"] + assert response["result"]["status"] == "ok" class TestHealthEndpoint: @@ -22,7 +23,7 @@ class TestHealthEndpoint: def test_health_from_MENU(self, client: socket.socket) -> None: """Test that health check returns status ok.""" response = api(client, "menu", {}) - assert response["state"] == "MENU" + assert response["result"]["state"] == "MENU" assert_health_response(api(client, "health", {})) def test_health_from_BLIND_SELECT(self, client: socket.socket) -> None: diff --git a/tests/lua/endpoints/test_load.py b/tests/lua/endpoints/test_load.py index 8766370..0dac86e 100644 --- a/tests/lua/endpoints/test_load.py +++ b/tests/lua/endpoints/test_load.py @@ -22,7 +22,7 @@ def test_load_from_fixture(self, client: socket.socket) -> None: fixture_path = get_fixture_path("load", "state-BLIND_SELECT") response = api(client, "load", {"path": str(fixture_path)}) assert_success_response(response) - assert response["path"] == str(fixture_path) + assert response["result"]["path"] == str(fixture_path) def test_load_save_roundtrip(self, client: socket.socket, tmp_path: Path) -> None: """Test that a loaded fixture can be saved and loaded again.""" diff --git a/tests/lua/endpoints/test_menu.py b/tests/lua/endpoints/test_menu.py index fb0a33c..b9602a6 100644 --- a/tests/lua/endpoints/test_menu.py +++ b/tests/lua/endpoints/test_menu.py @@ -9,9 +9,9 @@ def verify_base_menu_response(response: dict[str, Any]) -> None: """Verify that menu response has all base fields.""" # Verify state field - assert "state" in response - assert isinstance(response["state"], str) - assert len(response["state"]) > 0 + assert "state" in response["result"] + assert isinstance(response["result"]["state"], str) + assert len(response["result"]["state"]) > 0 class TestMenuEndpoint: diff --git a/tests/lua/endpoints/test_next_round.py b/tests/lua/endpoints/test_next_round.py index d06df19..a290e90 100644 --- a/tests/lua/endpoints/test_next_round.py +++ b/tests/lua/endpoints/test_next_round.py @@ -9,13 +9,13 @@ def verify_next_round_response(response: dict[str, Any]) -> None: """Verify that next_round response has expected fields.""" # Verify state field - should transition to BLIND_SELECT - assert "state" in response - assert isinstance(response["state"], str) - assert response["state"] == "BLIND_SELECT" + assert "state" in response["result"] + assert isinstance(response["result"]["state"], str) + assert response["result"]["state"] == "BLIND_SELECT" # Verify blinds field exists (we're at blind selection) - assert "blinds" in response - assert isinstance(response["blinds"], dict) + assert "blinds" in response["result"] + assert isinstance(response["result"]["blinds"], dict) class TestNextRoundEndpoint: diff --git a/tests/lua/endpoints/test_play.py b/tests/lua/endpoints/test_play.py index 7368c09..b76216a 100644 --- a/tests/lua/endpoints/test_play.py +++ b/tests/lua/endpoints/test_play.py @@ -43,9 +43,9 @@ def test_play_valid_cards_and_round_active(self, client: socket.socket) -> None: gamestate = load_fixture(client, "play", "state-SELECTING_HAND") assert gamestate["state"] == "SELECTING_HAND" response = api(client, "play", {"cards": [0, 3, 4, 5, 6]}) - assert response["state"] == "SELECTING_HAND" - assert response["hands"]["Flush"]["played_this_round"] == 1 - assert response["round"]["chips"] == 260 + assert response["result"]["state"] == "SELECTING_HAND" + assert response["result"]["hands"]["Flush"]["played_this_round"] == 1 + assert response["result"]["round"]["chips"] == 260 def test_play_valid_cards_and_round_won(self, client: socket.socket) -> None: """Test play endpoint from BLIND_SELECT state.""" @@ -55,7 +55,7 @@ def test_play_valid_cards_and_round_won(self, client: socket.socket) -> None: assert gamestate["state"] == "SELECTING_HAND" assert gamestate["round"]["chips"] == 200 response = api(client, "play", {"cards": [0, 3, 4, 5, 6]}) - assert response["state"] == "ROUND_EVAL" + assert response["result"]["state"] == "ROUND_EVAL" def test_play_valid_cards_and_game_won(self, client: socket.socket) -> None: """Test play endpoint from BLIND_SELECT state.""" @@ -69,7 +69,7 @@ def test_play_valid_cards_and_game_won(self, client: socket.socket) -> None: assert gamestate["blinds"]["boss"]["status"] == "CURRENT" assert gamestate["round"]["chips"] == 1000000 response = api(client, "play", {"cards": [0, 3, 4, 5, 6]}) - assert response["won"] is True + assert response["result"]["won"] is True def test_play_valid_cards_and_game_over(self, client: socket.socket) -> None: """Test play endpoint from BLIND_SELECT state.""" @@ -79,7 +79,7 @@ def test_play_valid_cards_and_game_over(self, client: socket.socket) -> None: assert gamestate["state"] == "SELECTING_HAND" assert gamestate["round"]["hands_left"] == 1 response = api(client, "play", {"cards": [0]}, timeout=5) - assert response["state"] == "GAME_OVER" + assert response["result"]["state"] == "GAME_OVER" class TestPlayEndpointValidation: diff --git a/tests/lua/endpoints/test_rearrange.py b/tests/lua/endpoints/test_rearrange.py index fadf76e..b09babb 100644 --- a/tests/lua/endpoints/test_rearrange.py +++ b/tests/lua/endpoints/test_rearrange.py @@ -22,7 +22,7 @@ def test_rearrange_hand(self, client: socket.socket) -> None: "rearrange", {"hand": permutation}, ) - ids = [card["id"] for card in response["hand"]["cards"]] + ids = [card["id"] for card in response["result"]["hand"]["cards"]] assert ids == [prev_ids[i] for i in permutation] def test_rearrange_jokers(self, client: socket.socket) -> None: @@ -39,7 +39,7 @@ def test_rearrange_jokers(self, client: socket.socket) -> None: "rearrange", {"jokers": permutation}, ) - ids = [card["id"] for card in response["jokers"]["cards"]] + ids = [card["id"] for card in response["result"]["jokers"]["cards"]] assert ids == [prev_ids[i] for i in permutation] def test_rearrange_consumables(self, client: socket.socket) -> None: @@ -56,7 +56,7 @@ def test_rearrange_consumables(self, client: socket.socket) -> None: "rearrange", {"consumables": permutation}, ) - ids = [card["id"] for card in response["consumables"]["cards"]] + ids = [card["id"] for card in response["result"]["consumables"]["cards"]] assert ids == [prev_ids[i] for i in permutation] diff --git a/tests/lua/endpoints/test_reroll.py b/tests/lua/endpoints/test_reroll.py index 59b9eab..3d4261a 100644 --- a/tests/lua/endpoints/test_reroll.py +++ b/tests/lua/endpoints/test_reroll.py @@ -12,7 +12,8 @@ def test_reroll_from_shop(self, client: socket.socket) -> None: """Test rerolling shop from SHOP state.""" gamestate = load_fixture(client, "reroll", "state-SHOP") assert gamestate["state"] == "SHOP" - after = api(client, "reroll", {}) + response = api(client, "reroll", {}) + after = response["result"] assert gamestate["state"] == "SHOP" assert after["state"] == "SHOP" assert gamestate["shop"] != after["shop"] diff --git a/tests/lua/endpoints/test_save.py b/tests/lua/endpoints/test_save.py index 34d3036..3837ce6 100644 --- a/tests/lua/endpoints/test_save.py +++ b/tests/lua/endpoints/test_save.py @@ -23,7 +23,7 @@ def test_save_from_BLIND_SELECT( temp_file = tmp_path / "save" response = api(client, "save", {"path": str(temp_file)}) assert_success_response(response) - assert response["path"] == str(temp_file) + assert response["result"]["path"] == str(temp_file) assert temp_file.exists() assert temp_file.stat().st_size > 0 diff --git a/tests/lua/endpoints/test_select.py b/tests/lua/endpoints/test_select.py index f904508..a186145 100644 --- a/tests/lua/endpoints/test_select.py +++ b/tests/lua/endpoints/test_select.py @@ -9,16 +9,16 @@ def verify_select_response(response: dict[str, Any]) -> None: """Verify that select response has expected fields.""" # Verify state field - should transition to SELECTING_HAND after selecting blind - assert "state" in response - assert isinstance(response["state"], str) - assert response["state"] == "SELECTING_HAND" + assert "state" in response["result"] + assert isinstance(response["result"]["state"], str) + assert response["result"]["state"] == "SELECTING_HAND" # Verify hand field exists - assert "hand" in response - assert isinstance(response["hand"], dict) + assert "hand" in response["result"] + assert isinstance(response["result"]["hand"], dict) # Verify we transitioned to SELECTING_HAND state - assert response["state"] == "SELECTING_HAND" + assert response["result"]["state"] == "SELECTING_HAND" class TestSelectEndpoint: @@ -61,7 +61,7 @@ class TestSelectEndpointStateRequirements: def test_select_from_MENU(self, client: socket.socket): """Test that select fails when not in BLIND_SELECT state.""" response = api(client, "menu", {}) - assert response["state"] == "MENU" + assert response["result"]["state"] == "MENU" assert_error_response( api(client, "select", {}), "INVALID_STATE", diff --git a/tests/lua/endpoints/test_sell.py b/tests/lua/endpoints/test_sell.py index 73adb35..f1bae8e 100644 --- a/tests/lua/endpoints/test_sell.py +++ b/tests/lua/endpoints/test_sell.py @@ -94,8 +94,9 @@ def test_sell_joker_in_SELECTING_HAND(self, client: socket.socket) -> None: assert gamestate["state"] == "SELECTING_HAND" assert gamestate["jokers"]["count"] == 1 response = api(client, "sell", {"joker": 0}) - assert response["jokers"]["count"] == 0 - assert gamestate["money"] < response["money"] + after = response["result"] + assert after["jokers"]["count"] == 0 + assert gamestate["money"] < after["money"] def test_sell_consumable_in_SELECTING_HAND(self, client: socket.socket) -> None: """Test selling a consumable in SELECTING_HAND state.""" @@ -105,8 +106,9 @@ def test_sell_consumable_in_SELECTING_HAND(self, client: socket.socket) -> None: assert gamestate["state"] == "SELECTING_HAND" assert gamestate["consumables"]["count"] == 1 response = api(client, "sell", {"consumable": 0}) - assert response["consumables"]["count"] == 0 - assert gamestate["money"] < response["money"] + after = response["result"] + assert after["consumables"]["count"] == 0 + assert gamestate["money"] < after["money"] def test_sell_joker_in_SHOP(self, client: socket.socket) -> None: """Test selling a joker in SHOP state.""" @@ -116,8 +118,9 @@ def test_sell_joker_in_SHOP(self, client: socket.socket) -> None: assert gamestate["state"] == "SHOP" assert gamestate["jokers"]["count"] == 1 response = api(client, "sell", {"joker": 0}) - assert response["jokers"]["count"] == 0 - assert gamestate["money"] < response["money"] + after = response["result"] + assert after["jokers"]["count"] == 0 + assert gamestate["money"] < after["money"] def test_sell_consumable_in_SHOP(self, client: socket.socket) -> None: """Test selling a consumable in SHOP state.""" @@ -127,8 +130,9 @@ def test_sell_consumable_in_SHOP(self, client: socket.socket) -> None: assert gamestate["state"] == "SHOP" assert gamestate["consumables"]["count"] == 1 response = api(client, "sell", {"consumable": 0}) - assert response["consumables"]["count"] == 0 - assert gamestate["money"] < response["money"] + after = response["result"] + assert after["consumables"]["count"] == 0 + assert gamestate["money"] < after["money"] class TestSellEndpointValidation: diff --git a/tests/lua/endpoints/test_set.py b/tests/lua/endpoints/test_set.py index 2da5ed2..5125191 100644 --- a/tests/lua/endpoints/test_set.py +++ b/tests/lua/endpoints/test_set.py @@ -45,7 +45,7 @@ def test_set_money(self, client: socket.socket) -> None: gamestate = load_fixture(client, "set", "state-SELECTING_HAND") assert gamestate["state"] == "SELECTING_HAND" response = api(client, "set", {"money": 100}) - assert response["money"] == 100 + assert response["result"]["money"] == 100 def test_set_negative_chips(self, client: socket.socket) -> None: """Test that set fails when chips is negative.""" @@ -63,7 +63,7 @@ def test_set_chips(self, client: socket.socket) -> None: gamestate = load_fixture(client, "set", "state-SELECTING_HAND") assert gamestate["state"] == "SELECTING_HAND" response = api(client, "set", {"chips": 100}) - assert response["round"]["chips"] == 100 + assert response["result"]["round"]["chips"] == 100 def test_set_negative_ante(self, client: socket.socket) -> None: """Test that set fails when ante is negative.""" @@ -81,7 +81,7 @@ def test_set_ante(self, client: socket.socket) -> None: gamestate = load_fixture(client, "set", "state-SELECTING_HAND") assert gamestate["state"] == "SELECTING_HAND" response = api(client, "set", {"ante": 8}) - assert response["ante_num"] == 8 + assert response["result"]["ante_num"] == 8 def test_set_negative_round(self, client: socket.socket) -> None: """Test that set fails when round is negative.""" @@ -99,7 +99,7 @@ def test_set_round(self, client: socket.socket) -> None: gamestate = load_fixture(client, "set", "state-SELECTING_HAND") assert gamestate["state"] == "SELECTING_HAND" response = api(client, "set", {"round": 5}) - assert response["round_num"] == 5 + assert response["result"]["round_num"] == 5 def test_set_negative_hands(self, client: socket.socket) -> None: """Test that set fails when hands is negative.""" @@ -117,7 +117,7 @@ def test_set_hands(self, client: socket.socket) -> None: gamestate = load_fixture(client, "set", "state-SELECTING_HAND") assert gamestate["state"] == "SELECTING_HAND" response = api(client, "set", {"hands": 10}) - assert response["round"]["hands_left"] == 10 + assert response["result"]["round"]["hands_left"] == 10 def test_set_negative_discards(self, client: socket.socket) -> None: """Test that set fails when discards is negative.""" @@ -135,7 +135,7 @@ def test_set_discards(self, client: socket.socket) -> None: gamestate = load_fixture(client, "set", "state-SELECTING_HAND") assert gamestate["state"] == "SELECTING_HAND" response = api(client, "set", {"discards": 10}) - assert response["round"]["discards_left"] == 10 + assert response["result"]["round"]["discards_left"] == 10 def test_set_shop_from_selecting_hand(self, client: socket.socket) -> None: """Test that set fails when shop is called from SELECTING_HAND state.""" @@ -153,7 +153,8 @@ def test_set_shop_from_SHOP(self, client: socket.socket) -> None: gamestate = load_fixture(client, "set", "state-SHOP") assert gamestate["state"] == "SHOP" before = gamestate - after = api(client, "set", {"shop": True}) + response = api(client, "set", {"shop": True}) + after = response["result"] assert len(after["shop"]["cards"]) > 0 assert len(before["shop"]["cards"]) > 0 assert after["shop"] != before["shop"] @@ -165,7 +166,8 @@ def test_set_shop_set_round_set_money(self, client: socket.socket) -> None: gamestate = load_fixture(client, "set", "state-SHOP") assert gamestate["state"] == "SHOP" before = gamestate - after = api(client, "set", {"shop": True, "round": 5, "money": 100}) + response = api(client, "set", {"shop": True, "round": 5, "money": 100}) + after = response["result"] assert after["shop"] != before["shop"] assert after["packs"] != before["packs"] assert after["vouchers"] != before["vouchers"] # here only the id is changed diff --git a/tests/lua/endpoints/test_skip.py b/tests/lua/endpoints/test_skip.py index d605670..1a7569f 100644 --- a/tests/lua/endpoints/test_skip.py +++ b/tests/lua/endpoints/test_skip.py @@ -9,13 +9,13 @@ def verify_skip_response(response: dict[str, Any]) -> None: """Verify that skip response has expected fields.""" # Verify state field - assert "state" in response - assert isinstance(response["state"], str) - assert response["state"] == "BLIND_SELECT" + assert "state" in response["result"] + assert isinstance(response["result"]["state"], str) + assert response["result"]["state"] == "BLIND_SELECT" # Verify blinds field exists - assert "blinds" in response - assert isinstance(response["blinds"], dict) + assert "blinds" in response["result"] + assert isinstance(response["result"]["blinds"], dict) class TestSkipEndpoint: @@ -30,8 +30,8 @@ def test_skip_small_blind(self, client: socket.socket) -> None: assert gamestate["blinds"]["small"]["status"] == "SELECT" response = api(client, "skip", {}) verify_skip_response(response) - assert response["blinds"]["small"]["status"] == "SKIPPED" - assert response["blinds"]["big"]["status"] == "SELECT" + assert response["result"]["blinds"]["small"]["status"] == "SKIPPED" + assert response["result"]["blinds"]["big"]["status"] == "SELECT" def test_skip_big_blind(self, client: socket.socket) -> None: """Test skipping Big blind in BLIND_SELECT state.""" @@ -42,8 +42,8 @@ def test_skip_big_blind(self, client: socket.socket) -> None: assert gamestate["blinds"]["big"]["status"] == "SELECT" response = api(client, "skip", {}) verify_skip_response(response) - assert response["blinds"]["big"]["status"] == "SKIPPED" - assert response["blinds"]["boss"]["status"] == "SELECT" + assert response["result"]["blinds"]["big"]["status"] == "SKIPPED" + assert response["result"]["blinds"]["boss"]["status"] == "SELECT" def test_skip_big_boss(self, client: socket.socket) -> None: """Test skipping Boss in BLIND_SELECT state.""" @@ -65,7 +65,7 @@ class TestSkipEndpointStateRequirements: def test_skip_from_MENU(self, client: socket.socket): """Test that skip fails when not in BLIND_SELECT state.""" response = api(client, "menu", {}) - assert response["state"] == "MENU" + assert response["result"]["state"] == "MENU" assert_error_response( api(client, "skip", {}), "INVALID_STATE", diff --git a/tests/lua/endpoints/test_start.py b/tests/lua/endpoints/test_start.py index 5f97dcc..09c566f 100644 --- a/tests/lua/endpoints/test_start.py +++ b/tests/lua/endpoints/test_start.py @@ -69,10 +69,10 @@ def test_start_from_MENU( ): """Test start endpoint with various valid parameters.""" response = api(client, "menu", {}) - assert response["state"] == "MENU" + assert response["result"]["state"] == "MENU" response = api(client, "start", arguments) for key, value in expected.items(): - assert response[key] == value + assert response["result"][key] == value class TestStartEndpointValidation: @@ -81,7 +81,7 @@ class TestStartEndpointValidation: def test_missing_deck_parameter(self, client: socket.socket): """Test that start fails when deck parameter is missing.""" response = api(client, "menu", {}) - assert response["state"] == "MENU" + assert response["result"]["state"] == "MENU" response = api(client, "start", {"stake": "WHITE"}) assert_error_response( response, @@ -92,7 +92,7 @@ def test_missing_deck_parameter(self, client: socket.socket): def test_missing_stake_parameter(self, client: socket.socket): """Test that start fails when stake parameter is missing.""" response = api(client, "menu", {}) - assert response["state"] == "MENU" + assert response["result"]["state"] == "MENU" response = api(client, "start", {"deck": "RED"}) assert_error_response( response, @@ -103,7 +103,7 @@ def test_missing_stake_parameter(self, client: socket.socket): def test_invalid_deck_value(self, client: socket.socket): """Test that start fails with invalid deck enum.""" response = api(client, "menu", {}) - assert response["state"] == "MENU" + assert response["result"]["state"] == "MENU" response = api(client, "start", {"deck": "INVALID_DECK", "stake": "WHITE"}) assert_error_response( response, @@ -114,7 +114,7 @@ def test_invalid_deck_value(self, client: socket.socket): def test_invalid_stake_value(self, client: socket.socket): """Test that start fails when invalid stake enum is provided.""" response = api(client, "menu", {}) - assert response["state"] == "MENU" + assert response["result"]["state"] == "MENU" response = api(client, "start", {"deck": "RED", "stake": "INVALID_STAKE"}) assert_error_response( response, @@ -125,7 +125,7 @@ def test_invalid_stake_value(self, client: socket.socket): def test_invalid_deck_type(self, client: socket.socket): """Test that start fails when deck is not a string.""" response = api(client, "menu", {}) - assert response["state"] == "MENU" + assert response["result"]["state"] == "MENU" response = api(client, "start", {"deck": 123, "stake": "WHITE"}) assert_error_response( response, @@ -136,7 +136,7 @@ def test_invalid_deck_type(self, client: socket.socket): def test_invalid_stake_type(self, client: socket.socket): """Test that start fails when stake is not a string.""" response = api(client, "menu", {}) - assert response["state"] == "MENU" + assert response["result"]["state"] == "MENU" response = api(client, "start", {"deck": "RED", "stake": 1}) assert_error_response( response, diff --git a/tests/lua/endpoints/test_use.py b/tests/lua/endpoints/test_use.py index 7ca6d58..5528fbb 100644 --- a/tests/lua/endpoints/test_use.py +++ b/tests/lua/endpoints/test_use.py @@ -19,7 +19,7 @@ def test_use_hermit_no_cards(self, client: socket.socket) -> None: assert gamestate["money"] == 12 assert gamestate["consumables"]["cards"][0]["key"] == "c_hermit" response = api(client, "use", {"consumable": 0}) - assert response["money"] == 12 * 2 + assert response["result"]["money"] == 12 * 2 def test_use_hermit_in_selecting_hand(self, client: socket.socket) -> None: """Test using The Hermit in SELECTING_HAND state.""" @@ -32,7 +32,7 @@ def test_use_hermit_in_selecting_hand(self, client: socket.socket) -> None: assert gamestate["money"] == 12 assert gamestate["consumables"]["cards"][0]["key"] == "c_hermit" response = api(client, "use", {"consumable": 0}) - assert response["money"] == 12 * 2 + assert response["result"]["money"] == 12 * 2 def test_use_temperance_no_cards(self, client: socket.socket) -> None: """Test using Temperance (no card selection).""" @@ -45,7 +45,7 @@ def test_use_temperance_no_cards(self, client: socket.socket) -> None: assert gamestate["jokers"]["count"] == 0 # no jokers => no money increase assert gamestate["consumables"]["cards"][0]["key"] == "c_temperance" response = api(client, "use", {"consumable": 0}) - assert response["money"] == gamestate["money"] + assert response["result"]["money"] == gamestate["money"] def test_use_planet_no_cards(self, client: socket.socket) -> None: """Test using a Planet card (no card selection).""" @@ -57,7 +57,7 @@ def test_use_planet_no_cards(self, client: socket.socket) -> None: assert gamestate["state"] == "SELECTING_HAND" assert gamestate["hands"]["High Card"]["level"] == 1 response = api(client, "use", {"consumable": 0}) - assert response["hands"]["High Card"]["level"] == 2 + assert response["result"]["hands"]["High Card"]["level"] == 2 def test_use_magician_with_one_card(self, client: socket.socket) -> None: """Test using The Magician with 1 card (min=1, max=2).""" @@ -68,7 +68,9 @@ def test_use_magician_with_one_card(self, client: socket.socket) -> None: ) assert gamestate["state"] == "SELECTING_HAND" response = api(client, "use", {"consumable": 1, "cards": [0]}) - assert response["hand"]["cards"][0]["modifier"]["enhancement"] == "LUCKY" + assert ( + response["result"]["hand"]["cards"][0]["modifier"]["enhancement"] == "LUCKY" + ) def test_use_magician_with_two_cards(self, client: socket.socket) -> None: """Test using The Magician with 2 cards.""" @@ -79,8 +81,12 @@ def test_use_magician_with_two_cards(self, client: socket.socket) -> None: ) assert gamestate["state"] == "SELECTING_HAND" response = api(client, "use", {"consumable": 1, "cards": [7, 5]}) - assert response["hand"]["cards"][5]["modifier"]["enhancement"] == "LUCKY" - assert response["hand"]["cards"][7]["modifier"]["enhancement"] == "LUCKY" + assert ( + response["result"]["hand"]["cards"][5]["modifier"]["enhancement"] == "LUCKY" + ) + assert ( + response["result"]["hand"]["cards"][7]["modifier"]["enhancement"] == "LUCKY" + ) def test_use_familiar_all_hand(self, client: socket.socket) -> None: """Test using Familiar (destroys cards, #G.hand.cards > 1).""" @@ -91,10 +97,10 @@ def test_use_familiar_all_hand(self, client: socket.socket) -> None: ) assert gamestate["state"] == "SELECTING_HAND" response = api(client, "use", {"consumable": 0}) - assert response["hand"]["count"] == gamestate["hand"]["count"] - 1 + 3 - assert response["hand"]["cards"][7]["set"] == "ENHANCED" - assert response["hand"]["cards"][8]["set"] == "ENHANCED" - assert response["hand"]["cards"][9]["set"] == "ENHANCED" + assert response["result"]["hand"]["count"] == gamestate["hand"]["count"] - 1 + 3 + assert response["result"]["hand"]["cards"][7]["set"] == "ENHANCED" + assert response["result"]["hand"]["cards"][8]["set"] == "ENHANCED" + assert response["result"]["hand"]["cards"][9]["set"] == "ENHANCED" class TestUseEndpointValidation: From 9296b01a6ed0ed66bf880e9310b41d4066a65aa1 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Fri, 5 Dec 2025 19:06:17 +0100 Subject: [PATCH 181/230] feat(lua.utils): improve response and request types --- src/lua/utils/types.lua | 230 ++++++++++++++++++++++------------------ 1 file changed, 129 insertions(+), 101 deletions(-) diff --git a/src/lua/utils/types.lua b/src/lua/utils/types.lua index dfa77cb..b509041 100644 --- a/src/lua/utils/types.lua +++ b/src/lua/utils/types.lua @@ -2,6 +2,10 @@ -- ========================================================================== -- 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 @@ -90,132 +94,146 @@ ---@field buy integer Buy price of the card (if in shop) -- ========================================================================== --- Schema Types --- ========================================================================== - ----@class SchemaField ----@field type "string"|"integer"|"array"|"boolean"|"table" ----@field required boolean? ----@field items "integer"? ----@field description string - ----@class Validator ----@field validate fun(args: table, schema: table): boolean, string?, string? - --- ========================================================================== --- Endpoint Type +-- 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 schema table Schema definition for arguments validation ---@field requires_state integer[]? Optional list of required game states ----@field execute fun(args: Request.Params, send_response: fun(response: EndpointResponse)) Execute function - --- ========================================================================== --- Core Infrastructure Types --- ========================================================================== +---@field execute fun(args: Request.Endpoint.Params | Request.Endpoint.Test.Params, send_response: fun(response: Response.Endpoint)) Execute function ----@class Dispatcher ----@field endpoints table ----@field Server Server? - ----@class Server ----@field host string ----@field port integer ----@field server_socket TCPSocketServer? ----@field client_socket TCPSocketClient? ----@field current_request_id integer|string|nil +---@class Endpoint.Schema +---@field type "string"|"integer"|"array"|"boolean"|"table" +---@field required boolean? +---@field items "integer"? +---@field description string -- ========================================================================== --- Request Types (JSON-RPC 2.0) +-- 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 +---@class Request.Server ---@field jsonrpc "2.0" ----@field method Request.Method Request method name. This corresponse to the endpoint name ----@field params Request.Params Params to use for the requests +---@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|nil Request ID ----@alias Request.Method ----| "echo" | "endpoint" | "error" | "state" | "validation" # Test Endpoints ----| "add" | "buy" | "cash_out" | "discard" | "gamestate" | "health" | "load" ----| "menu" | "next_round" | "play" | "rearrange" | "reroll" | "save" | "select" ----| "sell" | "set" | "skip" | "start" | "use" - ----@alias Request.Params ----| Endpoint.Add.Params ----| Endpoint.Buy.Params ----| Endpoint.CashOut.Params ----| Endpoint.Discard.Params ----| Endpoint.Gamestate.Params ----| Endpoint.Health.Params ----| Endpoint.Load.Params ----| Endpoint.Menu.Params ----| Endpoint.NextRound.Params ----| Endpoint.Play.Params ----| Endpoint.Rearrange.Params ----| Endpoint.Reroll.Params ----| Endpoint.Save.Params ----| Endpoint.Select.Params ----| Endpoint.Sell.Params ----| Endpoint.Set.Params ----| Endpoint.Skip.Params ----| Endpoint.Use.Params ----| Endpoint.Test.Echo.Params ----| Endpoint.Test.Endpoint.Params ----| Endpoint.Test.Error.Params ----| Endpoint.Test.State.Params ----| Endpoint.Test.Validation.Params +-- ========================================================================== +-- 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" | "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.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 -- ========================================================================== --- Response Types +-- 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 PathResponse +---@class Response.Endpoint.Path ---@field success boolean Whether the request was successful ---@field path string Path to the file ----@class HealthResponse +---@class Response.Endpoint.Health ---@field status "ok" ----@alias GameStateResponse +---@alias Response.Endpoint.GameState ---| GameState # Return the current game state of the game ----@class ErrorResponse ----@field message string Human-readable error message ----@field name ErrorName Error name (BAD_REQUEST, INVALID_STATE, etc.) - ----@class TestResponse +---@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) ----@alias EndpointResponse ----| HealthResponse ----| PathResponse ----| GameStateResponse ----| ErrorResponse ----| TestResponse +---@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 ResponseSuccess +---@class Response.Server.Success ---@field jsonrpc "2.0" ----@field result HealthResponse | PathResponse | GameStateResponse Response payload +---@field result Response.Endpoint.Health | Response.Endpoint.Path | Response.Endpoint.GameState | Response.Endpoint.Test Response payload ---@field id integer|string|nil Request ID ----@class ResponseError +---@class Response.Server.Error ---@field jsonrpc "2.0" ----@field error ResponseError.Error Response error +---@field error Response.Server.Error.Error Response error ---@field id integer|string|nil Request ID ----@class ResponseError.Error +---@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 ResponseError.Error.Data +---@field data table<'name', ErrorName> Semantic error code ----@class ResponseError.Error.Data ----@field name ErrorName Semantic error code +---@alias Response.Server +---| Response.Server.Success +---| Response.Server.Error -- ========================================================================== -- Error Types @@ -237,22 +255,32 @@ ---@alias ErrorCodes table -- ========================================================================== --- Settings Types +-- Core Infrastructure Types -- ========================================================================== ---@class Settings ----@field host string ----@field port integer ----@field headless boolean ----@field fast boolean ----@field render_on_api boolean ----@field audio boolean ----@field debug boolean ----@field no_shaders boolean - --- ========================================================================== --- Debug Types --- ========================================================================== +---@field host string Hostname for the TCP server (default: "127.0.0.1") +---@field port integer Port number for the TCP 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? +---@field log table? DebugPlus logger instance with debug/info/error methods (nil if DebugPlus not available) + +---@class Server +---@field host string Hostname for the TCP server (copied from Settings) +---@field port integer Port number for the TCP server (copied from Settings) +---@field server_socket TCPSocketServer? TCP server socket listening for connections (nil if not initialized) +---@field client_socket TCPSocketClient? TCP client socket for the connected client (nil if no client connected) +---@field current_request_id integer|string|nil Current JSON-RPC request ID being processed (nil if no active request) + +---@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) + +---@class Validator +---@field validate fun(args: table, schema: table): boolean, string?, string? Validates endpoint arguments against schema (returns success, error_message, error_code) From 9e0910b901e47f3c2db29d441ccbc2dd2bc66f01 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Fri, 5 Dec 2025 19:07:26 +0100 Subject: [PATCH 182/230] refactor(lua): make use of the new request/response format types --- src/lua/core/dispatcher.lua | 3 ++- src/lua/core/server.lua | 5 +++-- src/lua/core/validator.lua | 4 ++-- src/lua/endpoints/add.lua | 6 +++--- src/lua/endpoints/buy.lua | 6 +++--- src/lua/endpoints/cash_out.lua | 6 +++--- src/lua/endpoints/discard.lua | 6 +++--- src/lua/endpoints/gamestate.lua | 6 +++--- src/lua/endpoints/health.lua | 6 +++--- src/lua/endpoints/load.lua | 6 +++--- src/lua/endpoints/menu.lua | 6 +++--- src/lua/endpoints/next_round.lua | 6 +++--- src/lua/endpoints/play.lua | 6 +++--- src/lua/endpoints/rearrange.lua | 6 +++--- src/lua/endpoints/reroll.lua | 6 +++--- src/lua/endpoints/save.lua | 6 +++--- src/lua/endpoints/select.lua | 6 +++--- src/lua/endpoints/sell.lua | 6 +++--- src/lua/endpoints/set.lua | 6 +++--- src/lua/endpoints/skip.lua | 6 +++--- src/lua/endpoints/start.lua | 6 +++--- src/lua/endpoints/tests/echo.lua | 6 +++--- src/lua/endpoints/tests/endpoint.lua | 6 +++--- src/lua/endpoints/tests/error.lua | 6 +++--- src/lua/endpoints/tests/state.lua | 6 +++--- src/lua/endpoints/tests/validation.lua | 6 +++--- src/lua/endpoints/use.lua | 6 +++--- 27 files changed, 79 insertions(+), 77 deletions(-) diff --git a/src/lua/core/dispatcher.lua b/src/lua/core/dispatcher.lua index 016321e..584dba8 100644 --- a/src/lua/core/dispatcher.lua +++ b/src/lua/core/dispatcher.lua @@ -24,6 +24,7 @@ local function get_state_name(state_value) return STATE_NAME_CACHE[state_value] or tostring(state_value) end +---@type Dispatcher BB_DISPATCHER = { endpoints = {}, Server = nil, @@ -126,7 +127,7 @@ function BB_DISPATCHER.send_error(message, error_code) }) end ----@param request Request +---@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 diff --git a/src/lua/core/server.lua b/src/lua/core/server.lua index 6cbe740..a304c11 100644 --- a/src/lua/core/server.lua +++ b/src/lua/core/server.lua @@ -6,6 +6,7 @@ local socket = require("socket") local json = require("json") +---@type Server BB_SERVER = { host = BB_SETTINGS.host, port = BB_SETTINGS.port, @@ -63,7 +64,7 @@ function BB_SERVER.accept() end --- Max payload: 256 bytes. Non-blocking, returns empty array if no data. ----@return Request[] +---@return Request.Server[] function BB_SERVER.receive() if not BB_SERVER.client_socket then return {} @@ -118,7 +119,7 @@ function BB_SERVER.receive() return { parsed } end ----@param response EndpointResponse +---@param response Response.Endpoint ---@return boolean success function BB_SERVER.send_response(response) if not BB_SERVER.client_socket then diff --git a/src/lua/core/validator.lua b/src/lua/core/validator.lua index 19a4223..40b28e9 100644 --- a/src/lua/core/validator.lua +++ b/src/lua/core/validator.lua @@ -30,7 +30,7 @@ end ---@param field_name string ---@param value any ----@param field_schema SchemaField +---@param field_schema Endpoint.Schema ---@return boolean success ---@return string? error_message ---@return string? error_code @@ -68,7 +68,7 @@ local function validate_field(field_name, value, field_schema) end ---@param args table ----@param schema table +---@param schema table ---@return boolean success ---@return string? error_message ---@return string? error_code diff --git a/src/lua/endpoints/add.lua b/src/lua/endpoints/add.lua index 2f827c2..3e87d3d 100644 --- a/src/lua/endpoints/add.lua +++ b/src/lua/endpoints/add.lua @@ -4,7 +4,7 @@ -- Add Endpoint Params -- ========================================================================== ----@class Endpoint.Add.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) @@ -161,8 +161,8 @@ return { requires_state = { G.STATES.SELECTING_HAND, G.STATES.SHOP, G.STATES.ROUND_EVAL }, - ---@param args Endpoint.Add.Params - ---@param send_response fun(response: EndpointResponse) + ---@param args Request.Endpoint.Add.Params + ---@param send_response fun(response: Response.Endpoint) execute = function(args, send_response) sendDebugMessage("Init add()", "BB.ENDPOINTS") diff --git a/src/lua/endpoints/buy.lua b/src/lua/endpoints/buy.lua index 4f48045..b43ec97 100644 --- a/src/lua/endpoints/buy.lua +++ b/src/lua/endpoints/buy.lua @@ -4,7 +4,7 @@ -- Buy Endpoint Params -- ========================================================================== ----@class Endpoint.Buy.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 @@ -40,8 +40,8 @@ return { requires_state = { G.STATES.SHOP }, - ---@param args Endpoint.Buy.Params - ---@param send_response fun(response: EndpointResponse) + ---@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() diff --git a/src/lua/endpoints/cash_out.lua b/src/lua/endpoints/cash_out.lua index d2c6d37..03e7ee6 100644 --- a/src/lua/endpoints/cash_out.lua +++ b/src/lua/endpoints/cash_out.lua @@ -4,7 +4,7 @@ -- CashOut Endpoint Params -- ========================================================================== ----@class Endpoint.CashOut.Params +---@class Request.Endpoint.CashOut.Params -- ========================================================================== -- CashOut Endpoint @@ -21,8 +21,8 @@ return { requires_state = { G.STATES.ROUND_EVAL }, - ---@param _ Endpoint.CashOut.Params - ---@param send_response fun(response: EndpointResponse) + ---@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 = {} }) diff --git a/src/lua/endpoints/discard.lua b/src/lua/endpoints/discard.lua index da6de67..da0b5c0 100644 --- a/src/lua/endpoints/discard.lua +++ b/src/lua/endpoints/discard.lua @@ -4,7 +4,7 @@ -- Discard Endpoint Params -- ========================================================================== ----@class Endpoint.Discard.Params +---@class Request.Endpoint.Discard.Params ---@field cards integer[] 0-based indices of cards to discard -- ========================================================================== @@ -29,8 +29,8 @@ return { requires_state = { G.STATES.SELECTING_HAND }, - ---@param args Endpoint.Discard.Params - ---@param send_response fun(response: EndpointResponse) + ---@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 diff --git a/src/lua/endpoints/gamestate.lua b/src/lua/endpoints/gamestate.lua index a9a8a13..7d88100 100644 --- a/src/lua/endpoints/gamestate.lua +++ b/src/lua/endpoints/gamestate.lua @@ -4,7 +4,7 @@ -- Gamestate Endpoint Params -- ========================================================================== ----@class Endpoint.Gamestate.Params +---@class Request.Endpoint.Gamestate.Params -- ========================================================================== -- Gamestate Endpoint @@ -21,8 +21,8 @@ return { requires_state = nil, - ---@param _ Endpoint.Gamestate.Params - ---@param send_response fun(response: EndpointResponse) + ---@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() diff --git a/src/lua/endpoints/health.lua b/src/lua/endpoints/health.lua index 1232945..f43b412 100644 --- a/src/lua/endpoints/health.lua +++ b/src/lua/endpoints/health.lua @@ -4,7 +4,7 @@ -- Health Endpoint Params -- ========================================================================== ----@class Endpoint.Health.Params +---@class Request.Endpoint.Health.Params -- ========================================================================== -- Health Endpoint @@ -21,8 +21,8 @@ return { requires_state = nil, - ---@param _ Endpoint.Health.Params - ---@param send_response fun(response: EndpointResponse) + ---@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") diff --git a/src/lua/endpoints/load.lua b/src/lua/endpoints/load.lua index 191c7f4..f2ba1ad 100644 --- a/src/lua/endpoints/load.lua +++ b/src/lua/endpoints/load.lua @@ -4,7 +4,7 @@ -- Load Endpoint Params -- ========================================================================== ----@class Endpoint.Load.Params +---@class Request.Endpoint.Load.Params ---@field path string File path to the save file -- ========================================================================== @@ -34,8 +34,8 @@ return { requires_state = nil, - ---@param args Endpoint.Load.Params - ---@param send_response fun(response: EndpointResponse) + ---@param args Request.Endpoint.Load.Params + ---@param send_response fun(response: Response.Endpoint) execute = function(args, send_response) local path = args.path diff --git a/src/lua/endpoints/menu.lua b/src/lua/endpoints/menu.lua index aa14f2f..81861d2 100644 --- a/src/lua/endpoints/menu.lua +++ b/src/lua/endpoints/menu.lua @@ -4,7 +4,7 @@ -- Menu Endpoint Params -- ========================================================================== ----@class Endpoint.Menu.Params +---@class Request.Endpoint.Menu.Params -- ========================================================================== -- Menu Endpoint @@ -21,8 +21,8 @@ return { requires_state = nil, - ---@param _ Endpoint.Menu.Params - ---@param send_response fun(response: EndpointResponse) + ---@param _ Request.Endpoint.Menu.Params + ---@param send_response fun(response: Response.Endpoint) execute = function(_, send_response) sendDebugMessage("Init menu()", "BB.ENDPOINTS") G.FUNCS.go_to_menu({}) diff --git a/src/lua/endpoints/next_round.lua b/src/lua/endpoints/next_round.lua index c919d11..0210dc1 100644 --- a/src/lua/endpoints/next_round.lua +++ b/src/lua/endpoints/next_round.lua @@ -4,7 +4,7 @@ -- NextRound Endpoint Params -- ========================================================================== ----@class Endpoint.NextRound.Params +---@class Request.Endpoint.NextRound.Params -- ========================================================================== -- NextRound Endpoint @@ -21,8 +21,8 @@ return { requires_state = { G.STATES.SHOP }, - ---@param _ Endpoint.NextRound.Params - ---@param send_response fun(response: EndpointResponse) + ---@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({}) diff --git a/src/lua/endpoints/play.lua b/src/lua/endpoints/play.lua index 0a452e3..0bc08fa 100644 --- a/src/lua/endpoints/play.lua +++ b/src/lua/endpoints/play.lua @@ -4,7 +4,7 @@ -- Play Endpoint Params -- ========================================================================== ----@class Endpoint.Play.Params +---@class Request.Endpoint.Play.Params ---@field cards integer[] 0-based indices of cards to play -- ========================================================================== @@ -29,8 +29,8 @@ return { requires_state = { G.STATES.SELECTING_HAND }, - ---@param args Endpoint.Play.Params - ---@param send_response fun(response: EndpointResponse) + ---@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 diff --git a/src/lua/endpoints/rearrange.lua b/src/lua/endpoints/rearrange.lua index 47ca338..c195861 100644 --- a/src/lua/endpoints/rearrange.lua +++ b/src/lua/endpoints/rearrange.lua @@ -4,7 +4,7 @@ -- Rearrange Endpoint Params -- ========================================================================== ----@class Endpoint.Rearrange.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 @@ -43,8 +43,8 @@ return { requires_state = { G.STATES.SELECTING_HAND, G.STATES.SHOP }, - ---@param args Endpoint.Rearrange.Params - ---@param send_response fun(response: EndpointResponse) + ---@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) diff --git a/src/lua/endpoints/reroll.lua b/src/lua/endpoints/reroll.lua index 9383a5b..62b5863 100644 --- a/src/lua/endpoints/reroll.lua +++ b/src/lua/endpoints/reroll.lua @@ -4,7 +4,7 @@ -- Reroll Endpoint Params -- ========================================================================== ----@class Endpoint.Reroll.Params +---@class Request.Endpoint.Reroll.Params -- ========================================================================== -- Reroll Endpoint @@ -21,8 +21,8 @@ return { requires_state = { G.STATES.SHOP }, - ---@param _ Endpoint.Reroll.Params - ---@param send_response fun(response: EndpointResponse) + ---@param _ Request.Endpoint.Reroll.Params + ---@param send_response fun(response: Response.Endpoint) execute = function(_, send_response) -- Check affordability local reroll_cost = G.GAME.current_round and G.GAME.current_round.reroll_cost or 0 diff --git a/src/lua/endpoints/save.lua b/src/lua/endpoints/save.lua index 872f342..dd0b30e 100644 --- a/src/lua/endpoints/save.lua +++ b/src/lua/endpoints/save.lua @@ -4,7 +4,7 @@ -- Save Endpoint Params -- ========================================================================== ----@class Endpoint.Save.Params +---@class Request.Endpoint.Save.Params ---@field path string File path for the save file -- ========================================================================== @@ -49,8 +49,8 @@ return { G.STATES.NEW_ROUND, }, - ---@param args Endpoint.Save.Params - ---@param send_response fun(response: EndpointResponse) + ---@param args Request.Endpoint.Save.Params + ---@param send_response fun(response: Response.Endpoint) execute = function(args, send_response) local path = args.path diff --git a/src/lua/endpoints/select.lua b/src/lua/endpoints/select.lua index 5b1dafc..d59aac7 100644 --- a/src/lua/endpoints/select.lua +++ b/src/lua/endpoints/select.lua @@ -4,7 +4,7 @@ -- Select Endpoint Params -- ========================================================================== ----@class Endpoint.Select.Params +---@class Request.Endpoint.Select.Params -- ========================================================================== -- Select Endpoint @@ -21,8 +21,8 @@ return { requires_state = { G.STATES.BLIND_SELECT }, - ---@param _ Endpoint.Select.Params - ---@param send_response fun(response: EndpointResponse) + ---@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 diff --git a/src/lua/endpoints/sell.lua b/src/lua/endpoints/sell.lua index e21a9f1..4ef8e60 100644 --- a/src/lua/endpoints/sell.lua +++ b/src/lua/endpoints/sell.lua @@ -4,7 +4,7 @@ -- Sell Endpoint Params -- ========================================================================== ----@class Endpoint.Sell.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 @@ -34,8 +34,8 @@ return { requires_state = { G.STATES.SELECTING_HAND, G.STATES.SHOP }, - ---@param args Endpoint.Sell.Params - ---@param send_response fun(response: EndpointResponse) + ---@param args Request.Endpoint.Sell.Params + ---@param send_response fun(response: Response.Endpoint) execute = function(args, send_response) sendDebugMessage("Init sell()", "BB.ENDPOINTS") diff --git a/src/lua/endpoints/set.lua b/src/lua/endpoints/set.lua index cd2c7b1..a597669 100644 --- a/src/lua/endpoints/set.lua +++ b/src/lua/endpoints/set.lua @@ -4,7 +4,7 @@ -- Set Endpoint Params -- ========================================================================== ----@class Endpoint.Set.Params +---@class Request.Endpoint.Set.Params ---@field money integer? New money amount ---@field chips integer? New chips amount ---@field ante integer? New ante number @@ -64,8 +64,8 @@ return { requires_state = nil, - ---@param args Endpoint.Set.Params - ---@param send_response fun(response: EndpointResponse) + ---@param args Request.Endpoint.Set.Params + ---@param send_response fun(response: Response.Endpoint) execute = function(args, send_response) sendDebugMessage("Init set()", "BB.ENDPOINTS") diff --git a/src/lua/endpoints/skip.lua b/src/lua/endpoints/skip.lua index 7fbdc5b..0e684be 100644 --- a/src/lua/endpoints/skip.lua +++ b/src/lua/endpoints/skip.lua @@ -4,7 +4,7 @@ -- Skip Endpoint Params -- ========================================================================== ----@class Endpoint.Skip.Params +---@class Request.Endpoint.Skip.Params -- ========================================================================== -- Skip Endpoint @@ -21,8 +21,8 @@ return { requires_state = { G.STATES.BLIND_SELECT }, - ---@param _ Endpoint.Skip.Params - ---@param send_response fun(response: EndpointResponse) + ---@param _ Request.Endpoint.Skip.Params + ---@param send_response fun(response: Response.Endpoint) execute = function(_, send_response) sendDebugMessage("Init skip()", "BB.ENDPOINTS") diff --git a/src/lua/endpoints/start.lua b/src/lua/endpoints/start.lua index 8a7fbed..5bcefa6 100644 --- a/src/lua/endpoints/start.lua +++ b/src/lua/endpoints/start.lua @@ -4,7 +4,7 @@ -- Start Endpoint Params -- ========================================================================== ----@class Endpoint.Start.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 @@ -73,8 +73,8 @@ return { requires_state = { G.STATES.MENU }, - ---@param args Endpoint.Start.Params - ---@param send_response fun(response: EndpointResponse) + ---@param args Request.Endpoint.Start.Params + ---@param send_response fun(response: Response.Endpoint) execute = function(args, send_response) sendDebugMessage("Init start()", "BB.ENDPOINTS") diff --git a/src/lua/endpoints/tests/echo.lua b/src/lua/endpoints/tests/echo.lua index 8e8e2b4..af04cb3 100644 --- a/src/lua/endpoints/tests/echo.lua +++ b/src/lua/endpoints/tests/echo.lua @@ -4,7 +4,7 @@ -- Test Echo Endpoint Params -- ========================================================================== ----@class Endpoint.Test.Echo.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 @@ -57,8 +57,8 @@ return { requires_state = nil, - ---@param args Endpoint.Test.Echo.Params - ---@param send_response fun(response: EndpointResponse) + ---@param args Request.Endpoint.Test.Echo.Params + ---@param send_response fun(response: Response.Endpoint) execute = function(args, send_response) send_response({ success = true, diff --git a/src/lua/endpoints/tests/endpoint.lua b/src/lua/endpoints/tests/endpoint.lua index 0b88299..e44f360 100644 --- a/src/lua/endpoints/tests/endpoint.lua +++ b/src/lua/endpoints/tests/endpoint.lua @@ -4,7 +4,7 @@ -- Test Endpoint Endpoint Params -- ========================================================================== ----@class Endpoint.Test.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 @@ -57,8 +57,8 @@ return { requires_state = nil, - ---@param args Endpoint.Test.Endpoint.Params - ---@param send_response fun(response: EndpointResponse) + ---@param args Request.Endpoint.Test.Endpoint.Params + ---@param send_response fun(response: Response.Endpoint) execute = function(args, send_response) send_response({ success = true, diff --git a/src/lua/endpoints/tests/error.lua b/src/lua/endpoints/tests/error.lua index 6b396d9..e3eb91e 100644 --- a/src/lua/endpoints/tests/error.lua +++ b/src/lua/endpoints/tests/error.lua @@ -4,7 +4,7 @@ -- Test Error Endpoint Params -- ========================================================================== ----@class Endpoint.Test.Error.Params +---@class Request.Endpoint.Test.Error.Params ---@field error_type "throw_error"|"success" Whether to throw an error or succeed -- ========================================================================== @@ -29,8 +29,8 @@ return { requires_state = nil, - ---@param args Endpoint.Test.Error.Params - ---@param send_response fun(response: EndpointResponse) + ---@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") diff --git a/src/lua/endpoints/tests/state.lua b/src/lua/endpoints/tests/state.lua index d3fc4d6..5413661 100644 --- a/src/lua/endpoints/tests/state.lua +++ b/src/lua/endpoints/tests/state.lua @@ -4,7 +4,7 @@ -- Test State Endpoint Params -- ========================================================================== ----@class Endpoint.Test.State.Params +---@class Request.Endpoint.Test.State.Params -- ========================================================================== -- TestState Endpoint @@ -21,8 +21,8 @@ return { requires_state = { G.STATES.SPLASH, G.STATES.MENU }, - ---@param _ Endpoint.Test.State.Params - ---@param send_response fun(response: EndpointResponse) + ---@param _ Request.Endpoint.Test.State.Params + ---@param send_response fun(response: Response.Endpoint) execute = function(_, send_response) send_response({ success = true, diff --git a/src/lua/endpoints/tests/validation.lua b/src/lua/endpoints/tests/validation.lua index 78857b5..9eace0e 100644 --- a/src/lua/endpoints/tests/validation.lua +++ b/src/lua/endpoints/tests/validation.lua @@ -4,7 +4,7 @@ -- Test Validation Endpoint Params -- ========================================================================== ----@class Endpoint.Test.Validation.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 @@ -71,8 +71,8 @@ return { requires_state = nil, - ---@param args Endpoint.Test.Validation.Params - ---@param send_response fun(response: EndpointResponse) + ---@param args Request.Endpoint.Test.Validation.Params + ---@param send_response fun(response: Response.Endpoint) execute = function(args, send_response) send_response({ success = true, diff --git a/src/lua/endpoints/use.lua b/src/lua/endpoints/use.lua index 84c153f..31fac19 100644 --- a/src/lua/endpoints/use.lua +++ b/src/lua/endpoints/use.lua @@ -4,7 +4,7 @@ -- Use Endpoint Params -- ========================================================================== ----@class Endpoint.Use.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 @@ -35,8 +35,8 @@ return { requires_state = { G.STATES.SELECTING_HAND, G.STATES.SHOP }, - ---@param args Endpoint.Use.Params - ---@param send_response fun(response: EndpointResponse) + ---@param args Request.Endpoint.Use.Params + ---@param send_response fun(response: Response.Endpoint) execute = function(args, send_response) sendDebugMessage("Init use()", "BB.ENDPOINTS") From 3db1467ec133899c655cd852fdd407293d67fe53 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Fri, 5 Dec 2025 19:10:01 +0100 Subject: [PATCH 183/230] test(lua): define new assert helper functions --- tests/lua/conftest.py | 335 +++++++++++++++++++++++++++++------------- 1 file changed, 229 insertions(+), 106 deletions(-) diff --git a/tests/lua/conftest.py b/tests/lua/conftest.py index 8e3535c..1a285ef 100644 --- a/tests/lua/conftest.py +++ b/tests/lua/conftest.py @@ -17,6 +17,9 @@ PORT: int = 12346 # Default port for Balatro server BUFFER_SIZE: int = 65536 # 64KB buffer for TCP messages +# JSON-RPC 2.0 request ID counter +_request_id_counter: int = 0 + @pytest.fixture(scope="session") def host() -> str: @@ -55,50 +58,75 @@ def port() -> int: def api( client: socket.socket, - name: str, - arguments: dict = {}, + method: str, + params: dict = {}, timeout: int = 5, ) -> dict[str, Any]: - """Send an API call to the Balatro game and get the response. + """Send a JSON-RPC 2.0 API call to the Balatro game and get the response. Args: client: The TCP socket connected to the game. - name: The name of the API function to call. - arguments: Dictionary of arguments to pass to the API function (default: {}). + method: The name of the API method to call. + params: Dictionary of parameters to pass to the API method (default: {}). + timeout: Socket timeout in seconds (default: 5). Returns: - The game state response as a dictionary. + The raw JSON-RPC 2.0 response with either 'result' or 'error' field. """ - payload = {"name": name, "arguments": arguments} + global _request_id_counter + _request_id_counter += 1 + + payload = { + "jsonrpc": "2.0", + "method": method, + "params": params, + "id": _request_id_counter, + } client.send(json.dumps(payload).encode() + b"\n") client.settimeout(timeout) response = client.recv(BUFFER_SIZE) - gamestate = json.loads(response.decode().strip()) - return gamestate + parsed = json.loads(response.decode().strip()) + return parsed -def send_request(sock: socket.socket, name: str, arguments: dict[str, Any]) -> None: - """Send a JSON request to the server. +def send_request( + sock: socket.socket, + method: str, + params: dict[str, Any], + request_id: int | str | None = None, +) -> None: + """Send a JSON-RPC 2.0 request to the server. Args: sock: The TCP socket connected to the game. - name: The name of the endpoint to call. - arguments: Dictionary of arguments to pass to the endpoint. + 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). """ - request = {"name": name, "arguments": arguments} + 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, + } message = json.dumps(request) + "\n" sock.sendall(message.encode()) def receive_response(sock: socket.socket, timeout: float = 3.0) -> dict[str, Any]: - """Receive and parse JSON response from server. + """Receive and parse JSON-RPC 2.0 response from server. Args: sock: The TCP socket connected to the game. timeout: Socket timeout in seconds (default: 3.0). Returns: - The parsed JSON response as a dictionary. + The raw JSON-RPC 2.0 response with either 'result' or 'error' field. """ sock.settimeout(timeout) response = sock.recv(BUFFER_SIZE) @@ -111,7 +139,8 @@ def receive_response(sock: socket.socket, timeout: float = 3.0) -> dict[str, Any else: first_message = decoded.strip() - return json.loads(first_message) + parsed = json.loads(first_message) + return parsed def get_fixture_path(endpoint: str, fixture_name: str) -> Path: @@ -138,73 +167,6 @@ def create_temp_save_path() -> Path: return temp_dir / f"balatrobot_test_{uuid.uuid4().hex[:8]}.jkr" -# ============================================================================ -# Assertion Helpers -# ============================================================================ - - -def assert_success_response(response: dict[str, Any]) -> None: - """Validate success response structure. - - Args: - response: The response dictionary to validate. - - Raises: - AssertionError: If the response is not a valid success response. - """ - assert "success" in response, "Success response must have 'success' field" - assert response["success"] is True, "'success' field must be True" - assert "error" not in response, "Success response should not have 'error' field" - assert "error_code" not in response, ( - "Success response should not have 'error_code' field" - ) - - -def assert_error_response( - response: dict[str, Any], - expected_error_code: str | None = None, - expected_message_contains: str | None = None, -) -> None: - """Validate error response structure and content. - - Args: - response: The response dictionary to validate. - expected_error_code: The expected error code (optional). - expected_message_contains: Substring expected in error message (optional). - - Raises: - AssertionError: If the response is not a valid error response or doesn't match expectations. - """ - assert "error" in response, "Error response must have 'error' field" - assert "error_code" in response, "Error response must have 'error_code' field" - - assert isinstance(response["error"], str), "'error' must be a string" - assert isinstance(response["error_code"], str), "'error_code' must be a string" - - if expected_error_code: - assert response["error_code"] == expected_error_code, ( - f"Expected error_code '{expected_error_code}', got '{response['error_code']}'" - ) - - if expected_message_contains: - assert expected_message_contains.lower() in response["error"].lower(), ( - f"Expected error message to contain '{expected_message_contains}', got '{response['error']}'" - ) - - -def assert_health_response(response: dict[str, Any]) -> None: - """Validate health response structure. - - Args: - response: The response dictionary to validate. - - Raises: - AssertionError: If the response is not a valid health response. - """ - assert "status" in response, "Health response must have 'status' field" - assert response["status"] == "ok", "Health response 'status' must be 'ok'" - - def load_fixture( client: socket.socket, endpoint: str, @@ -220,24 +182,6 @@ def load_fixture( If the fixture file doesn't exist or cache=False, it will be automatically generated using the setup steps defined in fixtures.json. - - Args: - client: The TCP socket connected to the game. - endpoint: The endpoint directory name (e.g., "buy", "discard"). - fixture_name: Name of the fixture file (e.g., "state-SHOP.jkr"). - cache: If True, use existing fixture file. If False, regenerate (default: True). - - Returns: - The current gamestate after loading the fixture. - - Raises: - AssertionError: If the load operation or generation fails. - KeyError: If fixture definition not found in fixtures.json. - - Example: - gamestate = load_fixture(client, "buy", "state-SHOP.jkr") - response = api(client, "buy", {"card": 0}) - assert response["success"] """ fixture_path = get_fixture_path(endpoint, fixture_name) @@ -264,16 +208,195 @@ def load_fixture( # Check for errors during generation if "error" in response: + error_msg = response["error"]["message"] raise AssertionError( - f"Fixture generation failed at step {step_endpoint}: {response['error']}" + f"Fixture generation failed at step {step_endpoint}: {error_msg}" ) # Save the fixture fixture_path.parent.mkdir(parents=True, exist_ok=True) save_response = api(client, "save", {"path": str(fixture_path)}) - assert_success_response(save_response) + assert_path_response(save_response) # Load the fixture load_response = api(client, "load", {"path": str(fixture_path)}) - assert_success_response(load_response) - return api(client, "gamestate", {}) + 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. + """ + 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: The raw JSON-RPC 2.0 response. + expected_path: Optional expected path to verify. + + Returns: + The path from the response. + + Raises: + AssertionError: If response is not a valid PathResponse. + """ + 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 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: + response: The raw JSON-RPC 2.0 response. + **expected_fields: Optional field values to verify (e.g., state="SHOP", money=100). + + Returns: + The gamestate from the response. + + Raises: + AssertionError: If response is not a valid GameStateResponse. + """ + 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 + + +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. + + 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. + + 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}" + 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. + + 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). + + Returns: + The error data dict with 'name' field. + + 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}" + + 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}" + + 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 error["data"] From d2f105c80fef208fd7b6a977cb6b81bb53f1ed56 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Fri, 5 Dec 2025 19:10:35 +0100 Subject: [PATCH 184/230] test(lua): make use of the new assert helper functions --- tests/lua/core/test_validator.py | 30 ++++++++++---------- tests/lua/endpoints/test_gamestate.py | 41 ++++++--------------------- tests/lua/endpoints/test_health.py | 8 +----- tests/lua/endpoints/test_load.py | 10 +++---- tests/lua/endpoints/test_save.py | 8 +++--- 5 files changed, 34 insertions(+), 63 deletions(-) diff --git a/tests/lua/core/test_validator.py b/tests/lua/core/test_validator.py index 07a832a..d50ffd6 100644 --- a/tests/lua/core/test_validator.py +++ b/tests/lua/core/test_validator.py @@ -12,7 +12,7 @@ from tests.lua.conftest import ( api, assert_error_response, - assert_success_response, + assert_test_response, ) # ============================================================================ @@ -33,7 +33,7 @@ def test_valid_string_type(self, client: socket.socket) -> None: "string_field": "hello", }, ) - assert_success_response(response) + assert_test_response(response) def test_invalid_string_type(self, client: socket.socket) -> None: """Test that invalid string type fails validation.""" @@ -61,7 +61,7 @@ def test_valid_integer_type(self, client: socket.socket) -> None: "integer_field": 42, }, ) - assert_success_response(response) + assert_test_response(response) def test_invalid_integer_type_float(self, client: socket.socket) -> None: """Test that float fails integer validation.""" @@ -105,7 +105,7 @@ def test_valid_array_type(self, client: socket.socket) -> None: "array_field": [1, 2, 3], }, ) - assert_success_response(response) + assert_test_response(response) def test_invalid_array_type_not_sequential(self, client: socket.socket) -> None: """Test that non-sequential table fails array validation.""" @@ -149,7 +149,7 @@ def test_valid_boolean_type_true(self, client: socket.socket) -> None: "boolean_field": True, }, ) - assert_success_response(response) + assert_test_response(response) def test_valid_boolean_type_false(self, client: socket.socket) -> None: """Test that boolean false passes validation.""" @@ -161,7 +161,7 @@ def test_valid_boolean_type_false(self, client: socket.socket) -> None: "boolean_field": False, }, ) - assert_success_response(response) + assert_test_response(response) def test_invalid_boolean_type_string(self, client: socket.socket) -> None: """Test that string fails boolean validation.""" @@ -205,7 +205,7 @@ def test_valid_table_type(self, client: socket.socket) -> None: "table_field": {"key": "value", "nested": {"data": 123}}, }, ) - assert_success_response(response) + assert_test_response(response) def test_valid_table_type_empty(self, client: socket.socket) -> None: """Test that empty table passes validation.""" @@ -217,7 +217,7 @@ def test_valid_table_type_empty(self, client: socket.socket) -> None: "table_field": {}, }, ) - assert_success_response(response) + assert_test_response(response) def test_invalid_table_type_array(self, client: socket.socket) -> None: """Test that array fails table validation (arrays should use 'array' type).""" @@ -267,7 +267,7 @@ def test_required_field_present(self, client: socket.socket) -> None: "test_validation", {"required_field": "present"}, ) - assert_success_response(response) + assert_test_response(response) def test_required_field_missing(self, client: socket.socket) -> None: """Test that request without required field fails.""" @@ -292,7 +292,7 @@ def test_optional_field_missing(self, client: socket.socket) -> None: # All other fields are optional }, ) - assert_success_response(response) + assert_test_response(response) # ============================================================================ @@ -313,7 +313,7 @@ def test_array_of_integers_valid(self, client: socket.socket) -> None: "array_of_integers": [1, 2, 3], }, ) - assert_success_response(response) + assert_test_response(response) def test_array_of_integers_invalid_float(self, client: socket.socket) -> None: """Test that array with float items fails integer validation.""" @@ -394,7 +394,7 @@ def test_empty_arguments_with_only_required_field( "test_validation", {"required_field": "only this"}, ) - assert_success_response(response) + assert_test_response(response) def test_all_fields_provided(self, client: socket.socket) -> None: """Test request with multiple valid fields.""" @@ -411,7 +411,7 @@ def test_all_fields_provided(self, client: socket.socket) -> None: "array_of_integers": [4, 5, 6], }, ) - assert_success_response(response) + assert_test_response(response) def test_empty_array_when_allowed(self, client: socket.socket) -> None: """Test that empty array passes when no min constraint.""" @@ -423,7 +423,7 @@ def test_empty_array_when_allowed(self, client: socket.socket) -> None: "array_field": [], }, ) - assert_success_response(response) + assert_test_response(response) def test_empty_string_when_allowed(self, client: socket.socket) -> None: """Test that empty string passes when no min constraint.""" @@ -434,4 +434,4 @@ def test_empty_string_when_allowed(self, client: socket.socket) -> None: "required_field": "", # Empty but present }, ) - assert_success_response(response) + assert_test_response(response) diff --git a/tests/lua/endpoints/test_gamestate.py b/tests/lua/endpoints/test_gamestate.py index 581bcb7..3febb85 100644 --- a/tests/lua/endpoints/test_gamestate.py +++ b/tests/lua/endpoints/test_gamestate.py @@ -1,32 +1,8 @@ """Tests for src/lua/endpoints/gamestate.lua""" import socket -from typing import Any -from tests.lua.conftest import api, load_fixture - - -def verify_base_gamestate_response(response: dict[str, Any]) -> None: - """Verify that gamestate response has all base fields.""" - # Verify state field - assert "state" in response["result"] - assert isinstance(response["result"]["state"], str) - assert len(response["result"]["state"]) > 0 - - # Verify round_num field - assert "round_num" in response["result"] - assert isinstance(response["result"]["round_num"], int) - assert response["result"]["round_num"] >= 0 - - # Verify ante_num field - assert "ante_num" in response["result"] - assert isinstance(response["result"]["ante_num"], int) - assert response["result"]["ante_num"] >= 0 - - # Verify money field - assert "money" in response["result"] - assert isinstance(response["result"]["money"], int) - assert response["result"]["money"] >= 0 +from tests.lua.conftest import api, assert_gamestate_response, load_fixture class TestGamestateEndpoint: @@ -36,8 +12,7 @@ def test_gamestate_from_MENU(self, client: socket.socket) -> None: """Test that gamestate endpoint from MENU state is valid.""" api(client, "menu", {}) response = api(client, "gamestate", {}) - verify_base_gamestate_response(response) - assert response["result"]["state"] == "MENU" + assert_gamestate_response(response, state="MENU") def test_gamestate_from_BLIND_SELECT(self, client: socket.socket) -> None: """Test that gamestate from BLIND_SELECT state is valid.""" @@ -48,8 +23,10 @@ def test_gamestate_from_BLIND_SELECT(self, client: socket.socket) -> None: assert gamestate["deck"] == "RED" assert gamestate["stake"] == "WHITE" response = api(client, "gamestate", {}) - verify_base_gamestate_response(response) - assert response["result"]["state"] == "BLIND_SELECT" - assert response["result"]["round_num"] == 0 - assert response["result"]["deck"] == "RED" - assert response["result"]["stake"] == "WHITE" + assert_gamestate_response( + response, + state="BLIND_SELECT", + round_num=0, + deck="RED", + stake="WHITE", + ) diff --git a/tests/lua/endpoints/test_health.py b/tests/lua/endpoints/test_health.py index c156eae..f00ebd8 100644 --- a/tests/lua/endpoints/test_health.py +++ b/tests/lua/endpoints/test_health.py @@ -8,13 +8,7 @@ import socket from typing import Any -from tests.lua.conftest import api, load_fixture - - -def assert_health_response(response: dict[str, Any]) -> None: - assert "result" in response - assert "status" in response["result"] - assert response["result"]["status"] == "ok" +from tests.lua.conftest import api, assert_health_response, load_fixture class TestHealthEndpoint: diff --git a/tests/lua/endpoints/test_load.py b/tests/lua/endpoints/test_load.py index 0dac86e..0bc4479 100644 --- a/tests/lua/endpoints/test_load.py +++ b/tests/lua/endpoints/test_load.py @@ -6,7 +6,7 @@ from tests.lua.conftest import ( api, assert_error_response, - assert_success_response, + assert_path_response, get_fixture_path, load_fixture, ) @@ -21,7 +21,7 @@ def test_load_from_fixture(self, client: socket.socket) -> None: assert gamestate["state"] == "BLIND_SELECT" fixture_path = get_fixture_path("load", "state-BLIND_SELECT") response = api(client, "load", {"path": str(fixture_path)}) - assert_success_response(response) + assert_path_response(response) assert response["result"]["path"] == str(fixture_path) def test_load_save_roundtrip(self, client: socket.socket, tmp_path: Path) -> None: @@ -31,17 +31,17 @@ def test_load_save_roundtrip(self, client: socket.socket, tmp_path: Path) -> Non assert gamestate["state"] == "BLIND_SELECT" fixture_path = get_fixture_path("load", "state-BLIND_SELECT") load_response = api(client, "load", {"path": str(fixture_path)}) - assert_success_response(load_response) + assert_path_response(load_response) # Save to temp path temp_file = tmp_path / "save" save_response = api(client, "save", {"path": str(temp_file)}) - assert_success_response(save_response) + 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_success_response(load_again_response) + assert_path_response(load_again_response) class TestLoadValidation: diff --git a/tests/lua/endpoints/test_save.py b/tests/lua/endpoints/test_save.py index 3837ce6..0e94d32 100644 --- a/tests/lua/endpoints/test_save.py +++ b/tests/lua/endpoints/test_save.py @@ -6,7 +6,7 @@ from tests.lua.conftest import ( api, assert_error_response, - assert_success_response, + assert_path_response, load_fixture, ) @@ -22,7 +22,7 @@ def test_save_from_BLIND_SELECT( assert gamestate["state"] == "BLIND_SELECT" temp_file = tmp_path / "save" response = api(client, "save", {"path": str(temp_file)}) - assert_success_response(response) + assert_path_response(response) assert response["result"]["path"] == str(temp_file) assert temp_file.exists() assert temp_file.stat().st_size > 0 @@ -35,9 +35,9 @@ def test_save_creates_valid_file( assert gamestate["state"] == "BLIND_SELECT" temp_file = tmp_path / "save" save_response = api(client, "save", {"path": str(temp_file)}) - assert_success_response(save_response) + assert_path_response(save_response) load_response = api(client, "load", {"path": str(temp_file)}) - assert_success_response(load_response) + assert_path_response(load_response) class TestSaveValidation: From 82b4a234a7effeebb19e7665017472c20db9635c Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Sat, 6 Dec 2025 15:25:37 +0100 Subject: [PATCH 185/230] style(lua): formatting lua conftest.py --- tests/lua/conftest.py | 28 +++++++++++++++++++++------- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/tests/lua/conftest.py b/tests/lua/conftest.py index 1a285ef..5cddc00 100644 --- a/tests/lua/conftest.py +++ b/tests/lua/conftest.py @@ -272,7 +272,9 @@ def assert_path_response( 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}" + assert isinstance(result["path"], str), ( + f"PathResponse 'path' not a string: {result}" + ) if expected_path is not None: assert result["path"] == expected_path, ( @@ -306,7 +308,9 @@ def assert_gamestate_response( # 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}" + assert isinstance(result["state"], str), ( + f"GameStateResponse 'state' not a string: {result}" + ) # Verify any expected fields for field, expected_value in expected_fields.items(): @@ -345,13 +349,17 @@ def assert_test_response( 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 "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}" + assert "state_validated" in result, ( + f"TestResponse missing 'state_validated': {result}" + ) assert result["state_validated"] == expected_state_validated, ( f"TestResponse state_validated: expected {expected_state_validated}, got {result['state_validated']}" ) @@ -378,14 +386,20 @@ def assert_error_response( 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}" + assert "result" not in response, ( + f"Unexpected 'result' in error response: {response}" + ) 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}" + 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}" + ) if expected_error_name is not None: actual_name = error["data"]["name"] From ade21efade9eba7f12168586ebc851f46ffc2dc5 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Sat, 6 Dec 2025 15:26:14 +0100 Subject: [PATCH 186/230] test(lua.endpoints): make use of assert_gamestate_response in tests --- tests/lua/endpoints/test_add.py | 121 ++++++++++++++----------- tests/lua/endpoints/test_buy.py | 23 +++-- tests/lua/endpoints/test_cash_out.py | 23 ++--- tests/lua/endpoints/test_discard.py | 29 +++--- tests/lua/endpoints/test_health.py | 10 +- tests/lua/endpoints/test_menu.py | 17 +--- tests/lua/endpoints/test_next_round.py | 22 ++--- tests/lua/endpoints/test_play.py | 19 ++-- tests/lua/endpoints/test_rearrange.py | 42 +++++---- tests/lua/endpoints/test_reroll.py | 17 ++-- tests/lua/endpoints/test_select.py | 31 ++----- tests/lua/endpoints/test_sell.py | 47 +++++----- tests/lua/endpoints/test_set.py | 38 ++++---- tests/lua/endpoints/test_skip.py | 34 +++---- tests/lua/endpoints/test_start.py | 24 +++-- tests/lua/endpoints/test_use.py | 51 ++++++----- 16 files changed, 278 insertions(+), 270 deletions(-) diff --git a/tests/lua/endpoints/test_add.py b/tests/lua/endpoints/test_add.py index 1f54200..21395ba 100644 --- a/tests/lua/endpoints/test_add.py +++ b/tests/lua/endpoints/test_add.py @@ -4,7 +4,12 @@ import pytest -from tests.lua.conftest import api, assert_error_response, load_fixture +from tests.lua.conftest import ( + api, + assert_error_response, + assert_gamestate_response, + load_fixture, +) class TestAddEndpoint: @@ -20,8 +25,9 @@ def test_add_joker(self, client: socket.socket) -> None: assert gamestate["state"] == "SELECTING_HAND" assert gamestate["jokers"]["count"] == 0 response = api(client, "add", {"key": "j_joker"}) - assert response["result"]["jokers"]["count"] == 1 - assert response["result"]["jokers"]["cards"][0]["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: socket.socket) -> None: """Test adding a tarot consumable with valid key.""" @@ -33,8 +39,9 @@ def test_add_consumable_tarot(self, client: socket.socket) -> None: assert gamestate["state"] == "SELECTING_HAND" assert gamestate["consumables"]["count"] == 0 response = api(client, "add", {"key": "c_fool"}) - assert response["result"]["consumables"]["count"] == 1 - assert response["result"]["consumables"]["cards"][0]["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: socket.socket) -> None: """Test adding a planet consumable with valid key.""" @@ -46,8 +53,9 @@ def test_add_consumable_planet(self, client: socket.socket) -> None: assert gamestate["state"] == "SELECTING_HAND" assert gamestate["consumables"]["count"] == 0 response = api(client, "add", {"key": "c_mercury"}) - assert response["result"]["consumables"]["count"] == 1 - assert response["result"]["consumables"]["cards"][0]["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: socket.socket) -> None: """Test adding a spectral consumable with valid key.""" @@ -59,8 +67,9 @@ def test_add_consumable_spectral(self, client: socket.socket) -> None: assert gamestate["state"] == "SELECTING_HAND" assert gamestate["consumables"]["count"] == 0 response = api(client, "add", {"key": "c_familiar"}) - assert response["result"]["consumables"]["count"] == 1 - assert response["result"]["consumables"]["cards"][0]["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: socket.socket) -> None: """Test adding a voucher with valid key in SHOP state.""" @@ -72,8 +81,9 @@ def test_add_voucher(self, client: socket.socket) -> None: assert gamestate["state"] == "SHOP" assert gamestate["vouchers"]["count"] == 0 response = api(client, "add", {"key": "v_overstock_norm"}) - assert response["result"]["vouchers"]["count"] == 1 - assert response["result"]["vouchers"]["cards"][0]["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: socket.socket) -> None: """Test adding a playing card with valid key.""" @@ -85,8 +95,9 @@ def test_add_playing_card(self, client: socket.socket) -> None: assert gamestate["state"] == "SELECTING_HAND" assert gamestate["hand"]["count"] == 8 response = api(client, "add", {"key": "H_A"}) - assert response["result"]["hand"]["count"] == 9 - assert response["result"]["hand"]["cards"][8]["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: socket.socket) -> None: """Test add endpoint with no key parameter.""" @@ -205,9 +216,10 @@ def test_add_playing_card_with_seal(self, client: socket.socket, seal: str) -> N assert gamestate["state"] == "SELECTING_HAND" assert gamestate["hand"]["count"] == 8 response = api(client, "add", {"key": "H_A", "seal": seal}) - assert response["result"]["hand"]["count"] == 9 - assert response["result"]["hand"]["cards"][8]["key"] == "H_A" - assert response["result"]["hand"]["cards"][8]["modifier"]["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: socket.socket) -> None: """Test adding a playing card with invalid seal value.""" @@ -258,11 +270,10 @@ def test_add_joker_with_edition(self, client: socket.socket, edition: str) -> No assert gamestate["state"] == "SHOP" assert gamestate["jokers"]["count"] == 0 response = api(client, "add", {"key": "j_joker", "edition": edition}) - assert response["result"]["jokers"]["count"] == 1 - assert response["result"]["jokers"]["cards"][0]["key"] == "j_joker" - assert ( - response["result"]["jokers"]["cards"][0]["modifier"]["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( @@ -277,9 +288,10 @@ def test_add_playing_card_with_edition( assert gamestate["state"] == "SELECTING_HAND" assert gamestate["hand"]["count"] == 8 response = api(client, "add", {"key": "H_A", "edition": edition}) - assert response["result"]["hand"]["count"] == 9 - assert response["result"]["hand"]["cards"][8]["key"] == "H_A" - assert response["result"]["hand"]["cards"][8]["modifier"]["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: socket.socket) -> None: """Test adding a consumable with NEGATIVE edition (only valid edition for consumables).""" @@ -291,12 +303,10 @@ def test_add_consumable_with_negative_edition(self, client: socket.socket) -> No assert gamestate["state"] == "SHOP" assert gamestate["consumables"]["count"] == 0 response = api(client, "add", {"key": "c_fool", "edition": "NEGATIVE"}) - assert response["result"]["consumables"]["count"] == 1 - assert response["result"]["consumables"]["cards"][0]["key"] == "c_fool" - assert ( - response["result"]["consumables"]["cards"][0]["modifier"]["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( @@ -367,12 +377,10 @@ def test_add_playing_card_with_enhancement( assert gamestate["state"] == "SELECTING_HAND" assert gamestate["hand"]["count"] == 8 response = api(client, "add", {"key": "H_A", "enhancement": enhancement}) - assert response["result"]["hand"]["count"] == 9 - assert response["result"]["hand"]["cards"][8]["key"] == "H_A" - assert ( - response["result"]["hand"]["cards"][8]["modifier"]["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: socket.socket) -> None: """Test adding a playing card with invalid enhancement value.""" @@ -423,9 +431,10 @@ def test_add_joker_with_eternal(self, client: socket.socket) -> None: assert gamestate["state"] == "SHOP" assert gamestate["jokers"]["count"] == 0 response = api(client, "add", {"key": "j_joker", "eternal": True}) - assert response["result"]["jokers"]["count"] == 1 - assert response["result"]["jokers"]["cards"][0]["key"] == "j_joker" - assert response["result"]["jokers"]["cards"][0]["modifier"]["eternal"] is 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( @@ -473,11 +482,10 @@ def test_add_joker_with_perishable( assert gamestate["state"] == "SHOP" assert gamestate["jokers"]["count"] == 0 response = api(client, "add", {"key": "j_joker", "perishable": rounds}) - assert response["result"]["jokers"]["count"] == 1 - assert response["result"]["jokers"]["cards"][0]["key"] == "j_joker" - assert ( - response["result"]["jokers"]["cards"][0]["modifier"]["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: socket.socket) -> None: """Test adding a joker with both eternal and perishable stickers.""" @@ -491,10 +499,11 @@ def test_add_joker_with_eternal_and_perishable(self, client: socket.socket) -> N response = api( client, "add", {"key": "j_joker", "eternal": True, "perishable": 5} ) - assert response["result"]["jokers"]["count"] == 1 - assert response["result"]["jokers"]["cards"][0]["key"] == "j_joker" - assert response["result"]["jokers"]["cards"][0]["modifier"]["eternal"] is True - assert response["result"]["jokers"]["cards"][0]["modifier"]["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( @@ -580,9 +589,10 @@ def test_add_joker_with_rental(self, client: socket.socket) -> None: assert gamestate["state"] == "SHOP" assert gamestate["jokers"]["count"] == 0 response = api(client, "add", {"key": "j_joker", "rental": True}) - assert response["result"]["jokers"]["count"] == 1 - assert response["result"]["jokers"]["cards"][0]["key"] == "j_joker" - assert response["result"]["jokers"]["cards"][0]["modifier"]["rental"] is 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( @@ -614,10 +624,11 @@ def test_add_joker_with_rental_and_eternal(self, client: socket.socket) -> None: response = api( client, "add", {"key": "j_joker", "rental": True, "eternal": True} ) - assert response["result"]["jokers"]["count"] == 1 - assert response["result"]["jokers"]["cards"][0]["key"] == "j_joker" - assert response["result"]["jokers"]["cards"][0]["modifier"]["rental"] is True - assert response["result"]["jokers"]["cards"][0]["modifier"]["eternal"] is 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: socket.socket) -> None: """Test that rental cannot be applied to playing cards.""" diff --git a/tests/lua/endpoints/test_buy.py b/tests/lua/endpoints/test_buy.py index d4f0335..615abd8 100644 --- a/tests/lua/endpoints/test_buy.py +++ b/tests/lua/endpoints/test_buy.py @@ -4,7 +4,12 @@ import pytest -from tests.lua.conftest import api, assert_error_response, load_fixture +from tests.lua.conftest import ( + api, + assert_error_response, + assert_gamestate_response, + load_fixture, +) class TestBuyEndpoint: @@ -128,7 +133,8 @@ def test_buy_joker_success(self, client: socket.socket) -> None: assert gamestate["state"] == "SHOP" assert gamestate["shop"]["cards"][0]["set"] == "JOKER" response = api(client, "buy", {"card": 0}) - assert response["result"]["jokers"]["cards"][0]["set"] == "JOKER" + gamestate = assert_gamestate_response(response) + assert gamestate["jokers"]["cards"][0]["set"] == "JOKER" def test_buy_consumable_success(self, client: socket.socket) -> None: """Test buying a consumable card (Planet/Tarot/Spectral) from shop.""" @@ -136,7 +142,8 @@ def test_buy_consumable_success(self, client: socket.socket) -> None: assert gamestate["state"] == "SHOP" assert gamestate["shop"]["cards"][1]["set"] == "PLANET" response = api(client, "buy", {"card": 1}) - assert response["result"]["consumables"]["cards"][0]["set"] == "PLANET" + gamestate = assert_gamestate_response(response) + assert gamestate["consumables"]["cards"][0]["set"] == "PLANET" def test_buy_voucher_success(self, client: socket.socket) -> None: """Test buying a voucher from shop.""" @@ -146,8 +153,9 @@ def test_buy_voucher_success(self, client: socket.socket) -> None: assert gamestate["state"] == "SHOP" assert gamestate["vouchers"]["cards"][0]["set"] == "VOUCHER" response = api(client, "buy", {"voucher": 0}) - assert response["result"]["used_vouchers"] is not None - assert len(response["result"]["used_vouchers"]) > 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: socket.socket) -> None: """Test buying a pack from shop.""" @@ -160,8 +168,9 @@ def test_buy_packs_success(self, client: socket.socket) -> None: assert gamestate["packs"]["cards"][0]["label"] == "Buffoon Pack" assert gamestate["packs"]["cards"][1]["label"] == "Standard Pack" response = api(client, "buy", {"pack": 0}) - assert response["result"]["pack"] is not None - assert len(response["result"]["pack"]["cards"]) > 0 + gamestate = assert_gamestate_response(response) + assert gamestate["pack"] is not None + assert len(gamestate["pack"]["cards"]) > 0 class TestBuyEndpointValidation: diff --git a/tests/lua/endpoints/test_cash_out.py b/tests/lua/endpoints/test_cash_out.py index f4397e7..515079a 100644 --- a/tests/lua/endpoints/test_cash_out.py +++ b/tests/lua/endpoints/test_cash_out.py @@ -1,21 +1,13 @@ """Tests for src/lua/endpoints/cash_out.lua""" import socket -from typing import Any -from tests.lua.conftest import api, assert_error_response, load_fixture - - -def verify_cash_out_response(response: dict[str, Any]) -> None: - """Verify that cash_out response has expected fields.""" - # Verify state field - should transition to SHOP after cashing out - assert "state" in response["result"] - assert isinstance(response["result"]["state"], str) - assert response["result"]["state"] == "SHOP" - - # Verify shop field exists - assert "shop" in response["result"] - assert isinstance(response["result"]["shop"], dict) +from tests.lua.conftest import ( + api, + assert_error_response, + assert_gamestate_response, + load_fixture, +) class TestCashOutEndpoint: @@ -26,8 +18,7 @@ def test_cash_out_from_ROUND_EVAL(self, client: socket.socket) -> None: gamestate = load_fixture(client, "cash_out", "state-ROUND_EVAL") assert gamestate["state"] == "ROUND_EVAL" response = api(client, "cash_out", {}) - verify_cash_out_response(response) - assert response["result"]["state"] == "SHOP" + assert_gamestate_response(response, state="SHOP") class TestCashOutEndpointStateRequirements: diff --git a/tests/lua/endpoints/test_discard.py b/tests/lua/endpoints/test_discard.py index eb8137c..0311f9a 100644 --- a/tests/lua/endpoints/test_discard.py +++ b/tests/lua/endpoints/test_discard.py @@ -2,7 +2,12 @@ import socket -from tests.lua.conftest import api, assert_error_response, load_fixture +from tests.lua.conftest import ( + api, + assert_error_response, + assert_gamestate_response, + load_fixture, +) class TestDiscardEndpoint: @@ -53,25 +58,19 @@ def test_discard_no_discards_left(self, client: socket.socket) -> None: def test_discard_valid_single_card(self, client: socket.socket) -> None: """Test discard endpoint with valid single card.""" - gamestate = load_fixture(client, "discard", "state-SELECTING_HAND") - assert gamestate["state"] == "SELECTING_HAND" + before = load_fixture(client, "discard", "state-SELECTING_HAND") + assert before["state"] == "SELECTING_HAND" response = api(client, "discard", {"cards": [0]}) - assert response["result"]["state"] == "SELECTING_HAND" - assert ( - response["result"]["round"]["discards_left"] - == gamestate["round"]["discards_left"] - 1 - ) + 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: socket.socket) -> None: """Test discard endpoint with valid multiple cards.""" - gamestate = load_fixture(client, "discard", "state-SELECTING_HAND") - assert gamestate["state"] == "SELECTING_HAND" + before = load_fixture(client, "discard", "state-SELECTING_HAND") + assert before["state"] == "SELECTING_HAND" response = api(client, "discard", {"cards": [1, 2, 3]}) - assert response["result"]["state"] == "SELECTING_HAND" - assert ( - response["result"]["round"]["discards_left"] - == gamestate["round"]["discards_left"] - 1 - ) + after = assert_gamestate_response(response, state="SELECTING_HAND") + assert after["round"]["discards_left"] == before["round"]["discards_left"] - 1 class TestDiscardEndpointValidation: diff --git a/tests/lua/endpoints/test_health.py b/tests/lua/endpoints/test_health.py index f00ebd8..42449d5 100644 --- a/tests/lua/endpoints/test_health.py +++ b/tests/lua/endpoints/test_health.py @@ -6,9 +6,13 @@ # - Response structure and fields import socket -from typing import Any -from tests.lua.conftest import api, assert_health_response, load_fixture +from tests.lua.conftest import ( + api, + assert_gamestate_response, + assert_health_response, + load_fixture, +) class TestHealthEndpoint: @@ -17,7 +21,7 @@ class TestHealthEndpoint: def test_health_from_MENU(self, client: socket.socket) -> None: """Test that health check returns status ok.""" response = api(client, "menu", {}) - assert response["result"]["state"] == "MENU" + assert_gamestate_response(response, state="MENU") assert_health_response(api(client, "health", {})) def test_health_from_BLIND_SELECT(self, client: socket.socket) -> None: diff --git a/tests/lua/endpoints/test_menu.py b/tests/lua/endpoints/test_menu.py index b9602a6..9c60857 100644 --- a/tests/lua/endpoints/test_menu.py +++ b/tests/lua/endpoints/test_menu.py @@ -1,31 +1,22 @@ """Tests for src/lua/endpoints/menu.lua""" import socket -from typing import Any -from tests.lua.conftest import api, load_fixture - - -def verify_base_menu_response(response: dict[str, Any]) -> None: - """Verify that menu response has all base fields.""" - # Verify state field - assert "state" in response["result"] - assert isinstance(response["result"]["state"], str) - assert len(response["result"]["state"]) > 0 +from tests.lua.conftest import api, assert_gamestate_response, load_fixture class TestMenuEndpoint: - """Test basic menu endpoint and menu response structure.n""" + """Test basic menu endpoint and menu response structure.""" def test_menu_from_MENU(self, client: socket.socket) -> None: """Test that menu endpoint returns state as MENU.""" api(client, "menu", {}) response = api(client, "menu", {}) - verify_base_menu_response(response) + assert_gamestate_response(response, state="MENU") def test_menu_from_BLIND_SELECT(self, client: socket.socket) -> 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", {}) - verify_base_menu_response(response) + assert_gamestate_response(response, state="MENU") diff --git a/tests/lua/endpoints/test_next_round.py b/tests/lua/endpoints/test_next_round.py index a290e90..56a3664 100644 --- a/tests/lua/endpoints/test_next_round.py +++ b/tests/lua/endpoints/test_next_round.py @@ -1,21 +1,13 @@ """Tests for src/lua/endpoints/next_round.lua""" import socket -from typing import Any -from tests.lua.conftest import api, assert_error_response, load_fixture - - -def verify_next_round_response(response: dict[str, Any]) -> None: - """Verify that next_round response has expected fields.""" - # Verify state field - should transition to BLIND_SELECT - assert "state" in response["result"] - assert isinstance(response["result"]["state"], str) - assert response["result"]["state"] == "BLIND_SELECT" - - # Verify blinds field exists (we're at blind selection) - assert "blinds" in response["result"] - assert isinstance(response["result"]["blinds"], dict) +from tests.lua.conftest import ( + api, + assert_error_response, + assert_gamestate_response, + load_fixture, +) class TestNextRoundEndpoint: @@ -26,7 +18,7 @@ def test_next_round_from_shop(self, client: socket.socket) -> None: gamestate = load_fixture(client, "next_round", "state-SHOP") assert gamestate["state"] == "SHOP" response = api(client, "next_round", {}) - verify_next_round_response(response) + assert_gamestate_response(response, state="BLIND_SELECT") class TestNextRoundEndpointStateRequirements: diff --git a/tests/lua/endpoints/test_play.py b/tests/lua/endpoints/test_play.py index b76216a..f0affd5 100644 --- a/tests/lua/endpoints/test_play.py +++ b/tests/lua/endpoints/test_play.py @@ -2,7 +2,12 @@ import socket -from tests.lua.conftest import api, assert_error_response, load_fixture +from tests.lua.conftest import ( + api, + assert_error_response, + assert_gamestate_response, + load_fixture, +) class TestPlayEndpoint: @@ -43,9 +48,9 @@ def test_play_valid_cards_and_round_active(self, client: socket.socket) -> None: gamestate = load_fixture(client, "play", "state-SELECTING_HAND") assert gamestate["state"] == "SELECTING_HAND" response = api(client, "play", {"cards": [0, 3, 4, 5, 6]}) - assert response["result"]["state"] == "SELECTING_HAND" - assert response["result"]["hands"]["Flush"]["played_this_round"] == 1 - assert response["result"]["round"]["chips"] == 260 + 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: socket.socket) -> None: """Test play endpoint from BLIND_SELECT state.""" @@ -55,7 +60,7 @@ def test_play_valid_cards_and_round_won(self, client: socket.socket) -> None: assert gamestate["state"] == "SELECTING_HAND" assert gamestate["round"]["chips"] == 200 response = api(client, "play", {"cards": [0, 3, 4, 5, 6]}) - assert response["result"]["state"] == "ROUND_EVAL" + assert_gamestate_response(response, state="ROUND_EVAL") def test_play_valid_cards_and_game_won(self, client: socket.socket) -> None: """Test play endpoint from BLIND_SELECT state.""" @@ -69,7 +74,7 @@ def test_play_valid_cards_and_game_won(self, client: socket.socket) -> None: assert gamestate["blinds"]["boss"]["status"] == "CURRENT" assert gamestate["round"]["chips"] == 1000000 response = api(client, "play", {"cards": [0, 3, 4, 5, 6]}) - assert response["result"]["won"] is True + assert_gamestate_response(response, won=True) def test_play_valid_cards_and_game_over(self, client: socket.socket) -> None: """Test play endpoint from BLIND_SELECT state.""" @@ -79,7 +84,7 @@ def test_play_valid_cards_and_game_over(self, client: socket.socket) -> None: assert gamestate["state"] == "SELECTING_HAND" assert gamestate["round"]["hands_left"] == 1 response = api(client, "play", {"cards": [0]}, timeout=5) - assert response["result"]["state"] == "GAME_OVER" + assert_gamestate_response(response, state="GAME_OVER") class TestPlayEndpointValidation: diff --git a/tests/lua/endpoints/test_rearrange.py b/tests/lua/endpoints/test_rearrange.py index b09babb..1e21028 100644 --- a/tests/lua/endpoints/test_rearrange.py +++ b/tests/lua/endpoints/test_rearrange.py @@ -2,7 +2,12 @@ import socket -from tests.lua.conftest import api, assert_error_response, load_fixture +from tests.lua.conftest import ( + api, + assert_error_response, + assert_gamestate_response, + load_fixture, +) class TestRearrangeEndpoint: @@ -10,53 +15,54 @@ class TestRearrangeEndpoint: def test_rearrange_hand(self, client: socket.socket) -> None: """Test rearranging hand in selecting hand state.""" - gamestate = load_fixture( - client, "rearrange", "state-SELECTING_HAND--hand.count-8" - ) - assert gamestate["state"] == "SELECTING_HAND" - assert gamestate["hand"]["count"] == 8 - prev_ids = [card["id"] for card in gamestate["hand"]["cards"]] + 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}, ) - ids = [card["id"] for card in response["result"]["hand"]["cards"]] + 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: socket.socket) -> None: """Test rearranging jokers.""" - gamestate = load_fixture( + before = load_fixture( client, "rearrange", "state-SHOP--jokers.count-4--consumables.count-2" ) - assert gamestate["state"] == "SHOP" - assert gamestate["jokers"]["count"] == 4 - prev_ids = [card["id"] for card in gamestate["jokers"]["cards"]] + 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}, ) - ids = [card["id"] for card in response["result"]["jokers"]["cards"]] + 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: socket.socket) -> None: """Test rearranging consumables.""" - gamestate = load_fixture( + before = load_fixture( client, "rearrange", "state-SHOP--jokers.count-4--consumables.count-2" ) - assert gamestate["state"] == "SHOP" - assert gamestate["consumables"]["count"] == 2 - prev_ids = [card["id"] for card in gamestate["consumables"]["cards"]] + 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}, ) - ids = [card["id"] for card in response["result"]["consumables"]["cards"]] + after = assert_gamestate_response(response) + ids = [card["id"] for card in after["consumables"]["cards"]] assert ids == [prev_ids[i] for i in permutation] diff --git a/tests/lua/endpoints/test_reroll.py b/tests/lua/endpoints/test_reroll.py index 3d4261a..ce10e27 100644 --- a/tests/lua/endpoints/test_reroll.py +++ b/tests/lua/endpoints/test_reroll.py @@ -2,7 +2,12 @@ import socket -from tests.lua.conftest import api, assert_error_response, load_fixture +from tests.lua.conftest import ( + api, + assert_error_response, + assert_gamestate_response, + load_fixture, +) class TestRerollEndpoint: @@ -10,13 +15,11 @@ class TestRerollEndpoint: def test_reroll_from_shop(self, client: socket.socket) -> None: """Test rerolling shop from SHOP state.""" - gamestate = load_fixture(client, "reroll", "state-SHOP") - assert gamestate["state"] == "SHOP" + before = load_fixture(client, "reroll", "state-SHOP") + assert before["state"] == "SHOP" response = api(client, "reroll", {}) - after = response["result"] - assert gamestate["state"] == "SHOP" - assert after["state"] == "SHOP" - assert gamestate["shop"] != after["shop"] + after = assert_gamestate_response(response, state="SHOP") + assert before["shop"] != after["shop"] def test_reroll_insufficient_funds(self, client: socket.socket) -> None: """Test reroll endpoint when player has insufficient funds.""" diff --git a/tests/lua/endpoints/test_select.py b/tests/lua/endpoints/test_select.py index a186145..9131ae2 100644 --- a/tests/lua/endpoints/test_select.py +++ b/tests/lua/endpoints/test_select.py @@ -1,24 +1,13 @@ """Tests for src/lua/endpoints/select.lua""" import socket -from typing import Any -from tests.lua.conftest import api, assert_error_response, load_fixture - - -def verify_select_response(response: dict[str, Any]) -> None: - """Verify that select response has expected fields.""" - # Verify state field - should transition to SELECTING_HAND after selecting blind - assert "state" in response["result"] - assert isinstance(response["result"]["state"], str) - assert response["result"]["state"] == "SELECTING_HAND" - - # Verify hand field exists - assert "hand" in response["result"] - assert isinstance(response["result"]["hand"], dict) - - # Verify we transitioned to SELECTING_HAND state - assert response["result"]["state"] == "SELECTING_HAND" +from tests.lua.conftest import ( + api, + assert_error_response, + assert_gamestate_response, + load_fixture, +) class TestSelectEndpoint: @@ -32,7 +21,7 @@ def test_select_small_blind(self, client: socket.socket) -> None: assert gamestate["state"] == "BLIND_SELECT" assert gamestate["blinds"]["small"]["status"] == "SELECT" response = api(client, "select", {}) - verify_select_response(response) + assert_gamestate_response(response, state="SELECTING_HAND") def test_select_big_blind(self, client: socket.socket) -> None: """Test selecting Big blind in BLIND_SELECT state.""" @@ -42,7 +31,7 @@ def test_select_big_blind(self, client: socket.socket) -> None: assert gamestate["state"] == "BLIND_SELECT" assert gamestate["blinds"]["big"]["status"] == "SELECT" response = api(client, "select", {}) - verify_select_response(response) + assert_gamestate_response(response, state="SELECTING_HAND") def test_select_boss_blind(self, client: socket.socket) -> None: """Test selecting Boss blind in BLIND_SELECT state.""" @@ -52,7 +41,7 @@ def test_select_boss_blind(self, client: socket.socket) -> None: assert gamestate["state"] == "BLIND_SELECT" assert gamestate["blinds"]["boss"]["status"] == "SELECT" response = api(client, "select", {}) - verify_select_response(response) + assert_gamestate_response(response, state="SELECTING_HAND") class TestSelectEndpointStateRequirements: @@ -61,7 +50,7 @@ class TestSelectEndpointStateRequirements: def test_select_from_MENU(self, client: socket.socket): """Test that select fails when not in BLIND_SELECT state.""" response = api(client, "menu", {}) - assert response["result"]["state"] == "MENU" + assert_gamestate_response(response, state="MENU") assert_error_response( api(client, "select", {}), "INVALID_STATE", diff --git a/tests/lua/endpoints/test_sell.py b/tests/lua/endpoints/test_sell.py index f1bae8e..86ec555 100644 --- a/tests/lua/endpoints/test_sell.py +++ b/tests/lua/endpoints/test_sell.py @@ -2,7 +2,12 @@ import socket -from tests.lua.conftest import api, assert_error_response, load_fixture +from tests.lua.conftest import ( + api, + assert_error_response, + assert_gamestate_response, + load_fixture, +) class TestSellEndpoint: @@ -86,53 +91,53 @@ def test_sell_consumable_invalid_index(self, client: socket.socket) -> None: def test_sell_joker_in_SELECTING_HAND(self, client: socket.socket) -> None: """Test selling a joker in SELECTING_HAND state.""" - gamestate = load_fixture( + before = load_fixture( client, "sell", "state-SELECTING_HAND--jokers.count-1--consumables.count-1", ) - assert gamestate["state"] == "SELECTING_HAND" - assert gamestate["jokers"]["count"] == 1 + assert before["state"] == "SELECTING_HAND" + assert before["jokers"]["count"] == 1 response = api(client, "sell", {"joker": 0}) - after = response["result"] + after = assert_gamestate_response(response) assert after["jokers"]["count"] == 0 - assert gamestate["money"] < after["money"] + assert before["money"] < after["money"] def test_sell_consumable_in_SELECTING_HAND(self, client: socket.socket) -> None: """Test selling a consumable in SELECTING_HAND state.""" - gamestate = load_fixture( + before = load_fixture( client, "sell", "state-SELECTING_HAND--jokers.count-1--consumables.count-1" ) - assert gamestate["state"] == "SELECTING_HAND" - assert gamestate["consumables"]["count"] == 1 + assert before["state"] == "SELECTING_HAND" + assert before["consumables"]["count"] == 1 response = api(client, "sell", {"consumable": 0}) - after = response["result"] + after = assert_gamestate_response(response) assert after["consumables"]["count"] == 0 - assert gamestate["money"] < after["money"] + assert before["money"] < after["money"] def test_sell_joker_in_SHOP(self, client: socket.socket) -> None: """Test selling a joker in SHOP state.""" - gamestate = load_fixture( + before = load_fixture( client, "sell", "state-SHOP--jokers.count-1--consumables.count-1" ) - assert gamestate["state"] == "SHOP" - assert gamestate["jokers"]["count"] == 1 + assert before["state"] == "SHOP" + assert before["jokers"]["count"] == 1 response = api(client, "sell", {"joker": 0}) - after = response["result"] + after = assert_gamestate_response(response) assert after["jokers"]["count"] == 0 - assert gamestate["money"] < after["money"] + assert before["money"] < after["money"] def test_sell_consumable_in_SHOP(self, client: socket.socket) -> None: """Test selling a consumable in SHOP state.""" - gamestate = load_fixture( + before = load_fixture( client, "sell", "state-SHOP--jokers.count-1--consumables.count-1" ) - assert gamestate["state"] == "SHOP" - assert gamestate["consumables"]["count"] == 1 + assert before["state"] == "SHOP" + assert before["consumables"]["count"] == 1 response = api(client, "sell", {"consumable": 0}) - after = response["result"] + after = assert_gamestate_response(response) assert after["consumables"]["count"] == 0 - assert gamestate["money"] < after["money"] + assert before["money"] < after["money"] class TestSellEndpointValidation: diff --git a/tests/lua/endpoints/test_set.py b/tests/lua/endpoints/test_set.py index 5125191..91e34d7 100644 --- a/tests/lua/endpoints/test_set.py +++ b/tests/lua/endpoints/test_set.py @@ -2,7 +2,12 @@ import socket -from tests.lua.conftest import api, assert_error_response, load_fixture +from tests.lua.conftest import ( + api, + assert_error_response, + assert_gamestate_response, + load_fixture, +) class TestSetEndpoint: @@ -45,7 +50,7 @@ def test_set_money(self, client: socket.socket) -> None: gamestate = load_fixture(client, "set", "state-SELECTING_HAND") assert gamestate["state"] == "SELECTING_HAND" response = api(client, "set", {"money": 100}) - assert response["result"]["money"] == 100 + assert_gamestate_response(response, money=100) def test_set_negative_chips(self, client: socket.socket) -> None: """Test that set fails when chips is negative.""" @@ -63,7 +68,8 @@ def test_set_chips(self, client: socket.socket) -> None: gamestate = load_fixture(client, "set", "state-SELECTING_HAND") assert gamestate["state"] == "SELECTING_HAND" response = api(client, "set", {"chips": 100}) - assert response["result"]["round"]["chips"] == 100 + gamestate = assert_gamestate_response(response) + assert gamestate["round"]["chips"] == 100 def test_set_negative_ante(self, client: socket.socket) -> None: """Test that set fails when ante is negative.""" @@ -81,7 +87,7 @@ def test_set_ante(self, client: socket.socket) -> None: gamestate = load_fixture(client, "set", "state-SELECTING_HAND") assert gamestate["state"] == "SELECTING_HAND" response = api(client, "set", {"ante": 8}) - assert response["result"]["ante_num"] == 8 + assert_gamestate_response(response, ante_num=8) def test_set_negative_round(self, client: socket.socket) -> None: """Test that set fails when round is negative.""" @@ -99,7 +105,7 @@ def test_set_round(self, client: socket.socket) -> None: gamestate = load_fixture(client, "set", "state-SELECTING_HAND") assert gamestate["state"] == "SELECTING_HAND" response = api(client, "set", {"round": 5}) - assert response["result"]["round_num"] == 5 + assert_gamestate_response(response, round_num=5) def test_set_negative_hands(self, client: socket.socket) -> None: """Test that set fails when hands is negative.""" @@ -117,7 +123,8 @@ def test_set_hands(self, client: socket.socket) -> None: gamestate = load_fixture(client, "set", "state-SELECTING_HAND") assert gamestate["state"] == "SELECTING_HAND" response = api(client, "set", {"hands": 10}) - assert response["result"]["round"]["hands_left"] == 10 + gamestate = assert_gamestate_response(response) + assert gamestate["round"]["hands_left"] == 10 def test_set_negative_discards(self, client: socket.socket) -> None: """Test that set fails when discards is negative.""" @@ -135,7 +142,8 @@ def test_set_discards(self, client: socket.socket) -> None: gamestate = load_fixture(client, "set", "state-SELECTING_HAND") assert gamestate["state"] == "SELECTING_HAND" response = api(client, "set", {"discards": 10}) - assert response["result"]["round"]["discards_left"] == 10 + gamestate = assert_gamestate_response(response) + assert gamestate["round"]["discards_left"] == 10 def test_set_shop_from_selecting_hand(self, client: socket.socket) -> None: """Test that set fails when shop is called from SELECTING_HAND state.""" @@ -150,11 +158,10 @@ def test_set_shop_from_selecting_hand(self, client: socket.socket) -> None: def test_set_shop_from_SHOP(self, client: socket.socket) -> None: """Test that set fails when shop is called from SHOP state.""" - gamestate = load_fixture(client, "set", "state-SHOP") - assert gamestate["state"] == "SHOP" - before = gamestate + before = load_fixture(client, "set", "state-SHOP") + assert before["state"] == "SHOP" response = api(client, "set", {"shop": True}) - after = response["result"] + after = assert_gamestate_response(response) assert len(after["shop"]["cards"]) > 0 assert len(before["shop"]["cards"]) > 0 assert after["shop"] != before["shop"] @@ -163,16 +170,13 @@ def test_set_shop_from_SHOP(self, client: socket.socket) -> None: def test_set_shop_set_round_set_money(self, client: socket.socket) -> None: """Test that set fails when shop is called from SHOP state.""" - gamestate = load_fixture(client, "set", "state-SHOP") - assert gamestate["state"] == "SHOP" - before = gamestate + before = load_fixture(client, "set", "state-SHOP") + assert before["state"] == "SHOP" response = api(client, "set", {"shop": True, "round": 5, "money": 100}) - after = response["result"] + 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 - assert after["round_num"] == 5 - assert after["money"] == 100 class TestSetEndpointValidation: diff --git a/tests/lua/endpoints/test_skip.py b/tests/lua/endpoints/test_skip.py index 1a7569f..46770b7 100644 --- a/tests/lua/endpoints/test_skip.py +++ b/tests/lua/endpoints/test_skip.py @@ -1,21 +1,13 @@ """Tests for src/lua/endpoints/skip.lua""" import socket -from typing import Any -from tests.lua.conftest import api, assert_error_response, load_fixture - - -def verify_skip_response(response: dict[str, Any]) -> None: - """Verify that skip response has expected fields.""" - # Verify state field - assert "state" in response["result"] - assert isinstance(response["result"]["state"], str) - assert response["result"]["state"] == "BLIND_SELECT" - - # Verify blinds field exists - assert "blinds" in response["result"] - assert isinstance(response["result"]["blinds"], dict) +from tests.lua.conftest import ( + api, + assert_error_response, + assert_gamestate_response, + load_fixture, +) class TestSkipEndpoint: @@ -29,9 +21,9 @@ def test_skip_small_blind(self, client: socket.socket) -> None: assert gamestate["state"] == "BLIND_SELECT" assert gamestate["blinds"]["small"]["status"] == "SELECT" response = api(client, "skip", {}) - verify_skip_response(response) - assert response["result"]["blinds"]["small"]["status"] == "SKIPPED" - assert response["result"]["blinds"]["big"]["status"] == "SELECT" + 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: socket.socket) -> None: """Test skipping Big blind in BLIND_SELECT state.""" @@ -41,9 +33,9 @@ def test_skip_big_blind(self, client: socket.socket) -> None: assert gamestate["state"] == "BLIND_SELECT" assert gamestate["blinds"]["big"]["status"] == "SELECT" response = api(client, "skip", {}) - verify_skip_response(response) - assert response["result"]["blinds"]["big"]["status"] == "SKIPPED" - assert response["result"]["blinds"]["boss"]["status"] == "SELECT" + 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: socket.socket) -> None: """Test skipping Boss in BLIND_SELECT state.""" @@ -65,7 +57,7 @@ class TestSkipEndpointStateRequirements: def test_skip_from_MENU(self, client: socket.socket): """Test that skip fails when not in BLIND_SELECT state.""" response = api(client, "menu", {}) - assert response["result"]["state"] == "MENU" + assert_gamestate_response(response, state="MENU") assert_error_response( api(client, "skip", {}), "INVALID_STATE", diff --git a/tests/lua/endpoints/test_start.py b/tests/lua/endpoints/test_start.py index 09c566f..36d08f1 100644 --- a/tests/lua/endpoints/test_start.py +++ b/tests/lua/endpoints/test_start.py @@ -5,7 +5,12 @@ import pytest -from tests.lua.conftest import api, assert_error_response, load_fixture +from tests.lua.conftest import ( + api, + assert_error_response, + assert_gamestate_response, + load_fixture, +) class TestStartEndpoint: @@ -69,10 +74,9 @@ def test_start_from_MENU( ): """Test start endpoint with various valid parameters.""" response = api(client, "menu", {}) - assert response["result"]["state"] == "MENU" + assert_gamestate_response(response, state="MENU") response = api(client, "start", arguments) - for key, value in expected.items(): - assert response["result"][key] == value + assert_gamestate_response(response, **expected) class TestStartEndpointValidation: @@ -81,7 +85,7 @@ class TestStartEndpointValidation: def test_missing_deck_parameter(self, client: socket.socket): """Test that start fails when deck parameter is missing.""" response = api(client, "menu", {}) - assert response["result"]["state"] == "MENU" + assert_gamestate_response(response, state="MENU") response = api(client, "start", {"stake": "WHITE"}) assert_error_response( response, @@ -92,7 +96,7 @@ def test_missing_deck_parameter(self, client: socket.socket): def test_missing_stake_parameter(self, client: socket.socket): """Test that start fails when stake parameter is missing.""" response = api(client, "menu", {}) - assert response["result"]["state"] == "MENU" + assert_gamestate_response(response, state="MENU") response = api(client, "start", {"deck": "RED"}) assert_error_response( response, @@ -103,7 +107,7 @@ def test_missing_stake_parameter(self, client: socket.socket): def test_invalid_deck_value(self, client: socket.socket): """Test that start fails with invalid deck enum.""" response = api(client, "menu", {}) - assert response["result"]["state"] == "MENU" + assert_gamestate_response(response, state="MENU") response = api(client, "start", {"deck": "INVALID_DECK", "stake": "WHITE"}) assert_error_response( response, @@ -114,7 +118,7 @@ def test_invalid_deck_value(self, client: socket.socket): def test_invalid_stake_value(self, client: socket.socket): """Test that start fails when invalid stake enum is provided.""" response = api(client, "menu", {}) - assert response["result"]["state"] == "MENU" + assert_gamestate_response(response, state="MENU") response = api(client, "start", {"deck": "RED", "stake": "INVALID_STAKE"}) assert_error_response( response, @@ -125,7 +129,7 @@ def test_invalid_stake_value(self, client: socket.socket): def test_invalid_deck_type(self, client: socket.socket): """Test that start fails when deck is not a string.""" response = api(client, "menu", {}) - assert response["result"]["state"] == "MENU" + assert_gamestate_response(response, state="MENU") response = api(client, "start", {"deck": 123, "stake": "WHITE"}) assert_error_response( response, @@ -136,7 +140,7 @@ def test_invalid_deck_type(self, client: socket.socket): def test_invalid_stake_type(self, client: socket.socket): """Test that start fails when stake is not a string.""" response = api(client, "menu", {}) - assert response["result"]["state"] == "MENU" + assert_gamestate_response(response, state="MENU") response = api(client, "start", {"deck": "RED", "stake": 1}) assert_error_response( response, diff --git a/tests/lua/endpoints/test_use.py b/tests/lua/endpoints/test_use.py index 5528fbb..65ed375 100644 --- a/tests/lua/endpoints/test_use.py +++ b/tests/lua/endpoints/test_use.py @@ -2,7 +2,12 @@ import socket -from tests.lua.conftest import api, assert_error_response, load_fixture +from tests.lua.conftest import ( + api, + assert_error_response, + assert_gamestate_response, + load_fixture, +) class TestUseEndpoint: @@ -19,7 +24,7 @@ def test_use_hermit_no_cards(self, client: socket.socket) -> None: assert gamestate["money"] == 12 assert gamestate["consumables"]["cards"][0]["key"] == "c_hermit" response = api(client, "use", {"consumable": 0}) - assert response["result"]["money"] == 12 * 2 + assert_gamestate_response(response, money=24) def test_use_hermit_in_selecting_hand(self, client: socket.socket) -> None: """Test using The Hermit in SELECTING_HAND state.""" @@ -32,20 +37,20 @@ def test_use_hermit_in_selecting_hand(self, client: socket.socket) -> None: assert gamestate["money"] == 12 assert gamestate["consumables"]["cards"][0]["key"] == "c_hermit" response = api(client, "use", {"consumable": 0}) - assert response["result"]["money"] == 12 * 2 + assert_gamestate_response(response, money=24) def test_use_temperance_no_cards(self, client: socket.socket) -> None: """Test using Temperance (no card selection).""" - gamestate = load_fixture( + before = load_fixture( client, "use", "state-SELECTING_HAND--consumables.cards[0]-key-c_temperance--jokers.count-0", ) - assert gamestate["state"] == "SELECTING_HAND" - assert gamestate["jokers"]["count"] == 0 # no jokers => no money increase - assert gamestate["consumables"]["cards"][0]["key"] == "c_temperance" + 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 response["result"]["money"] == gamestate["money"] + assert_gamestate_response(response, money=before["money"]) def test_use_planet_no_cards(self, client: socket.socket) -> None: """Test using a Planet card (no card selection).""" @@ -57,7 +62,8 @@ def test_use_planet_no_cards(self, client: socket.socket) -> None: assert gamestate["state"] == "SELECTING_HAND" assert gamestate["hands"]["High Card"]["level"] == 1 response = api(client, "use", {"consumable": 0}) - assert response["result"]["hands"]["High Card"]["level"] == 2 + after = assert_gamestate_response(response) + assert after["hands"]["High Card"]["level"] == 2 def test_use_magician_with_one_card(self, client: socket.socket) -> None: """Test using The Magician with 1 card (min=1, max=2).""" @@ -68,9 +74,8 @@ def test_use_magician_with_one_card(self, client: socket.socket) -> None: ) assert gamestate["state"] == "SELECTING_HAND" response = api(client, "use", {"consumable": 1, "cards": [0]}) - assert ( - response["result"]["hand"]["cards"][0]["modifier"]["enhancement"] == "LUCKY" - ) + after = assert_gamestate_response(response) + assert after["hand"]["cards"][0]["modifier"]["enhancement"] == "LUCKY" def test_use_magician_with_two_cards(self, client: socket.socket) -> None: """Test using The Magician with 2 cards.""" @@ -81,26 +86,24 @@ def test_use_magician_with_two_cards(self, client: socket.socket) -> None: ) assert gamestate["state"] == "SELECTING_HAND" response = api(client, "use", {"consumable": 1, "cards": [7, 5]}) - assert ( - response["result"]["hand"]["cards"][5]["modifier"]["enhancement"] == "LUCKY" - ) - assert ( - response["result"]["hand"]["cards"][7]["modifier"]["enhancement"] == "LUCKY" - ) + 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: socket.socket) -> None: """Test using Familiar (destroys cards, #G.hand.cards > 1).""" - gamestate = load_fixture( + before = load_fixture( client, "use", "state-SELECTING_HAND--consumables.cards[0]-key-c_familiar", ) - assert gamestate["state"] == "SELECTING_HAND" + assert before["state"] == "SELECTING_HAND" response = api(client, "use", {"consumable": 0}) - assert response["result"]["hand"]["count"] == gamestate["hand"]["count"] - 1 + 3 - assert response["result"]["hand"]["cards"][7]["set"] == "ENHANCED" - assert response["result"]["hand"]["cards"][8]["set"] == "ENHANCED" - assert response["result"]["hand"]["cards"][9]["set"] == "ENHANCED" + 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: From 017e4633af98e43ee2983593c99f2217e26f10f2 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Sat, 6 Dec 2025 17:27:57 +0100 Subject: [PATCH 187/230] feat(lua.endpoints): add screenshot endpoint --- balatrobot.lua | 2 + src/lua/endpoints/screenshot.lua | 73 ++++++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+) create mode 100644 src/lua/endpoints/screenshot.lua diff --git a/balatrobot.lua b/balatrobot.lua index 917641a..6923e3d 100644 --- a/balatrobot.lua +++ b/balatrobot.lua @@ -13,6 +13,8 @@ BB_ENDPOINTS = { -- 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", 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, +} From 3264152d417f16397a278101e0a240b3777a842a Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Sat, 6 Dec 2025 17:28:10 +0100 Subject: [PATCH 188/230] feat(lua.utils): add screenshot endpoint types --- src/lua/utils/types.lua | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/lua/utils/types.lua b/src/lua/utils/types.lua index b509041..4f0544b 100644 --- a/src/lua/utils/types.lua +++ b/src/lua/utils/types.lua @@ -140,9 +140,9 @@ -- ========================================================================== ---@alias Request.Endpoint.Method ----| "add" | "buy" | "cash_out" | "discard" | "gamestate" | "health" ----| "load" | "menu" | "next_round" | "play" | "rearrange" | "reroll" ----| "save" | "select" | "sell" | "set" | "skip" | "start" | "use" +---| "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" @@ -161,6 +161,7 @@ ---| 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 From a9b11cb45253cb6e65375b0475fb4387a7ba6263 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Sat, 6 Dec 2025 17:28:27 +0100 Subject: [PATCH 189/230] test(fixtures): add screenshot endpoint fixtures for tests --- tests/fixtures/fixtures.json | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tests/fixtures/fixtures.json b/tests/fixtures/fixtures.json index a294877..7c5c525 100644 --- a/tests/fixtures/fixtures.json +++ b/tests/fixtures/fixtures.json @@ -1859,5 +1859,21 @@ } } ] + }, + "screenshot": { + "state-BLIND_SELECT": [ + { + "endpoint": "menu", + "arguments": {} + }, + { + "endpoint": "start", + "arguments": { + "deck": "RED", + "stake": "WHITE", + "seed": "TEST123" + } + } + ] } } From 16961af21c04b6fbd1a25bfc4ff2dd02d2ec540f Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Sat, 6 Dec 2025 17:28:42 +0100 Subject: [PATCH 190/230] test(lua.endpoints): add screenshot endpoint tests --- tests/lua/endpoints/test_screenshot.py | 66 ++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 tests/lua/endpoints/test_screenshot.py diff --git a/tests/lua/endpoints/test_screenshot.py b/tests/lua/endpoints/test_screenshot.py new file mode 100644 index 0000000..cb328ef --- /dev/null +++ b/tests/lua/endpoints/test_screenshot.py @@ -0,0 +1,66 @@ +"""Tests for src/lua/endpoints/screenshot.lua""" + +import socket +from pathlib import Path + +import pytest + +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: socket.socket, 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: socket.socket, 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: socket.socket) -> 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: socket.socket) -> 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", + ) From 24a5764a17057d61a1c7b489836d90d4cefab0cf Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Sat, 6 Dec 2025 17:30:04 +0100 Subject: [PATCH 191/230] chore(lua.endpoints): remove unused imports in test_screenshot --- tests/lua/endpoints/test_screenshot.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/lua/endpoints/test_screenshot.py b/tests/lua/endpoints/test_screenshot.py index cb328ef..57c4f22 100644 --- a/tests/lua/endpoints/test_screenshot.py +++ b/tests/lua/endpoints/test_screenshot.py @@ -3,8 +3,6 @@ import socket from pathlib import Path -import pytest - from tests.lua.conftest import ( api, assert_error_response, From fa2f026e1c348c2892791d8d7cb2fe656b194389 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Sun, 7 Dec 2025 19:59:06 +0100 Subject: [PATCH 192/230] feat(lua.core): add rpc.discover endpoint to the dispatcher --- src/lua/core/dispatcher.lua | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/lua/core/dispatcher.lua b/src/lua/core/dispatcher.lua index 584dba8..08dfe4e 100644 --- a/src/lua/core/dispatcher.lua +++ b/src/lua/core/dispatcher.lua @@ -134,6 +134,23 @@ function BB_DISPATCHER.dispatch(request) 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 From 584aaf8d5df032bddbcec9588d2bf564d074ca2a Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Sun, 7 Dec 2025 20:05:40 +0100 Subject: [PATCH 193/230] feat(lua.core): move from raw TCP to HTTP server --- src/lua/core/server.lua | 384 ++++++++++++++++++++++++++++++++++------ 1 file changed, 330 insertions(+), 54 deletions(-) diff --git a/src/lua/core/server.lua b/src/lua/core/server.lua index a304c11..3e2c92b 100644 --- a/src/lua/core/server.lua +++ b/src/lua/core/server.lua @@ -1,11 +1,75 @@ --[[ - TCP Server - Single-client, non-blocking server on port 12346. - JSON-RPC 2.0 protocol with newline-delimited messages. + 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, @@ -13,36 +77,69 @@ BB_SERVER = { 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 - sendDebugMessage("Listening on " .. BB_SERVER.host .. ":" .. BB_SERVER.port, "BB.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 @@ -50,83 +147,248 @@ function BB_SERVER.accept() 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 ---- Max payload: 256 bytes. Non-blocking, returns empty array if no data. ----@return Request.Server[] -function BB_SERVER.receive() - if not BB_SERVER.client_socket then - return {} +--- 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 - BB_SERVER.client_socket:settimeout(0) - local line, err = BB_SERVER.client_socket:receive("*l") - if not line then - if err == "closed" then - BB_SERVER.client_socket:close() - BB_SERVER.client_socket = nil +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 - return {} + + -- 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 - if #line + 1 > 256 then + 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 = "Request too large: maximum 256 bytes including newline", + message = "Invalid JSON in request body", name = BB_ERROR_NAMES.BAD_REQUEST, }) - return {} + return end - if line == "" then - return {} + + -- 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 - local trimmed = line:match("^%s*(.-)%s*$") - if not trimmed:match("^{") then + + -- 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 JSON in request: must be object (start with '{')", + message = "Invalid Request: 'id' field is required", name = BB_ERROR_NAMES.BAD_REQUEST, }) - return {} + return end - local success, parsed = pcall(json.decode, line) - if not success or type(parsed) ~= "table" then + + 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 JSON in request", + message = "Invalid Request: 'id' must be an integer or string", name = BB_ERROR_NAMES.BAD_REQUEST, }) - return {} + return end - if parsed.jsonrpc ~= "2.0" then - BB_SERVER.current_request_id = parsed.id + + if id_type == "number" and parsed.id ~= math.floor(parsed.id) then + BB_SERVER.current_request_id = nil BB_SERVER.send_response({ - message = "Invalid JSON-RPC version: expected '2.0'", + message = "Invalid Request: 'id' must be an integer, not a float", name = BB_ERROR_NAMES.BAD_REQUEST, }) - return {} + return end + BB_SERVER.current_request_id = parsed.id - return { parsed } + + -- 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 = { @@ -139,51 +401,65 @@ function BB_SERVER.send_response(response) 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 - local _, err = BB_SERVER.client_socket:send(json_str .. "\n") - if err then - sendDebugMessage("Failed to send response: " .. err, "BB.SERVER") - return false - end - return true + + -- 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 ----@param dispatcher Dispatcher? +--- 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() - if BB_SERVER.client_socket then - local requests = BB_SERVER.receive() - for _, request in ipairs(requests) do - if dispatcher and dispatcher.dispatch then - dispatcher.dispatch(request, BB_SERVER.client_socket) - else - BB_SERVER.send_response({ - message = "Server not fully initialized (dispatcher not ready)", - name = BB_ERROR_NAMES.INVALID_STATE, - }) + + -- 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() - if BB_SERVER.client_socket then - BB_SERVER.client_socket:close() - BB_SERVER.client_socket = nil - end + close_client() + if BB_SERVER.server_socket then BB_SERVER.server_socket:close() BB_SERVER.server_socket = nil From 39d6f5ce9446c66abbcd062219262f6bc8595556 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Sun, 7 Dec 2025 20:06:23 +0100 Subject: [PATCH 194/230] feat(lua.utils): update types with new dispatcher and server fields --- src/lua/utils/types.lua | 32 ++++++++++++++++++++++---------- 1 file changed, 22 insertions(+), 10 deletions(-) diff --git a/src/lua/utils/types.lua b/src/lua/utils/types.lua index 4f0544b..b0151a2 100644 --- a/src/lua/utils/types.lua +++ b/src/lua/utils/types.lua @@ -129,7 +129,7 @@ ---@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|nil Request ID +---@field id integer|string Request ID (required) -- ========================================================================== -- Endpoint Request Types @@ -220,12 +220,12 @@ ---@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|nil Request ID +---@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 +---@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 @@ -260,8 +260,8 @@ -- ========================================================================== ---@class Settings ----@field host string Hostname for the TCP server (default: "127.0.0.1") ----@field port integer Port number for the TCP server (default: 12346) +---@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) @@ -273,15 +273,27 @@ ---@field log table? DebugPlus logger instance with debug/info/error methods (nil if DebugPlus not available) ---@class Server ----@field host string Hostname for the TCP server (copied from Settings) ----@field port integer Port number for the TCP server (copied from Settings) ----@field server_socket TCPSocketServer? TCP server socket listening for connections (nil if not initialized) ----@field client_socket TCPSocketClient? TCP client socket for the connected client (nil if no client connected) ----@field current_request_id integer|string|nil Current JSON-RPC request ID being processed (nil if no active request) +---@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) From 6f1c13285af30c1c9e0e72a2c697dca25de4538d Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Sun, 7 Dec 2025 20:08:47 +0100 Subject: [PATCH 195/230] chore: add httpx to deps and update uv.lock --- pyproject.toml | 2 +- uv.lock | 74 ++++++++++++++++++++++++++++++++++---------------- 2 files changed, 52 insertions(+), 24 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 687f025..6f64717 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,7 +44,7 @@ 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", diff --git a/uv.lock b/uv.lock index 723697f..d4db93c 100644 --- a/uv.lock +++ b/uv.lock @@ -11,6 +11,18 @@ 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" }, ] +[[package]] +name = "anyio" +version = "4.12.0" +source = { registry = "https://pypi.org/simple" } +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/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" @@ -45,7 +57,7 @@ dependencies = [ [package.dev-dependencies] dev = [ { name = "basedpyright" }, - { name = "deepdiff" }, + { name = "httpx" }, { name = "mdformat-mkdocs" }, { name = "mdformat-simple-breaks" }, { name = "mkdocs-llmstxt" }, @@ -65,7 +77,7 @@ 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" }, @@ -187,18 +199,6 @@ wheels = [ { 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" }, -] - [[package]] name = "execnet" version = "2.1.1" @@ -232,6 +232,43 @@ 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" }, ] +[[package]] +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 = "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/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/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" @@ -580,15 +617,6 @@ wheels = [ { 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" } -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" }, -] - [[package]] name = "packaging" version = "25.0" From efbd2d4bd92da23e8209e4860442bd1673d61f88 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Sun, 7 Dec 2025 20:09:06 +0100 Subject: [PATCH 196/230] test(lua): update test helper functions to use httpx --- tests/lua/conftest.py | 78 ++++++++++++++++--------------------------- 1 file changed, 29 insertions(+), 49 deletions(-) diff --git a/tests/lua/conftest.py b/tests/lua/conftest.py index 5cddc00..0edc3e1 100644 --- a/tests/lua/conftest.py +++ b/tests/lua/conftest.py @@ -1,12 +1,12 @@ """Lua API test-specific configuration and fixtures.""" import json -import socket import tempfile import uuid from pathlib import Path from typing import Any, Generator +import httpx import pytest # ============================================================================ @@ -15,7 +15,8 @@ HOST: str = "127.0.0.1" # Default host for Balatro server PORT: int = 12346 # Default port for Balatro server -BUFFER_SIZE: int = 65536 # 64KB buffer for TCP messages +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 @@ -28,21 +29,21 @@ def host() -> str: @pytest.fixture -def client(host: str, port: int) -> Generator[socket.socket, None, None]: - """Create a TCP socket client connected to Balatro game instance. +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: - A connected TCP socket for communicating with the game. + An httpx.Client for communicating with the game. """ - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: - sock.settimeout(60) # 60 second timeout for operations - 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 @pytest.fixture(scope="session") @@ -57,18 +58,18 @@ def port() -> int: def api( - client: socket.socket, + client: httpx.Client, method: str, params: dict = {}, - timeout: int = 5, + timeout: float = REQUEST_TIMEOUT, ) -> dict[str, Any]: """Send a JSON-RPC 2.0 API call to the Balatro game and get the response. Args: - client: The TCP socket connected to the game. + 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: Socket timeout in seconds (default: 5). + timeout: Request timeout in seconds (default: 5.0). Returns: The raw JSON-RPC 2.0 response with either 'result' or 'error' field. @@ -82,26 +83,30 @@ def api( "params": params, "id": _request_id_counter, } - client.send(json.dumps(payload).encode() + b"\n") - client.settimeout(timeout) - response = client.recv(BUFFER_SIZE) - parsed = json.loads(response.decode().strip()) - return parsed + + response = client.post("/", json=payload, timeout=timeout) + response.raise_for_status() + return response.json() def send_request( - sock: socket.socket, + client: httpx.Client, method: str, params: dict[str, Any], request_id: int | str | None = None, -) -> None: + timeout: float = REQUEST_TIMEOUT, +) -> httpx.Response: """Send a JSON-RPC 2.0 request to the server. Args: - sock: The TCP socket connected to the game. + 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. """ global _request_id_counter if request_id is None: @@ -114,33 +119,8 @@ def send_request( "params": params, "id": request_id, } - message = json.dumps(request) + "\n" - sock.sendall(message.encode()) - - -def receive_response(sock: socket.socket, timeout: float = 3.0) -> dict[str, Any]: - """Receive and parse JSON-RPC 2.0 response from server. - - Args: - sock: The TCP socket connected to the game. - timeout: Socket timeout in seconds (default: 3.0). - - Returns: - The raw JSON-RPC 2.0 response with either 'result' or 'error' field. - """ - sock.settimeout(timeout) - response = sock.recv(BUFFER_SIZE) - decoded = response.decode() - - # Parse first complete message - first_newline = decoded.find("\n") - if first_newline != -1: - first_message = decoded[:first_newline] - else: - first_message = decoded.strip() - parsed = json.loads(first_message) - return parsed + return client.post("/", json=request, timeout=timeout) def get_fixture_path(endpoint: str, fixture_name: str) -> Path: @@ -168,7 +148,7 @@ def create_temp_save_path() -> Path: def load_fixture( - client: socket.socket, + client: httpx.Client, endpoint: str, fixture_name: str, cache: bool = True, From 9ba587ea71dad5473f87461339c74b5c4b2e8eff Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Sun, 7 Dec 2025 20:09:34 +0100 Subject: [PATCH 197/230] test(lua.core): update core tests to use httpx --- tests/lua/core/test_dispatcher.py | 88 ++-- tests/lua/core/test_server.py | 775 ++++++++++++++++++------------ tests/lua/core/test_validator.py | 56 +-- 3 files changed, 530 insertions(+), 389 deletions(-) diff --git a/tests/lua/core/test_dispatcher.py b/tests/lua/core/test_dispatcher.py index 0542d8e..267d584 100644 --- a/tests/lua/core/test_dispatcher.py +++ b/tests/lua/core/test_dispatcher.py @@ -9,10 +9,9 @@ - TestDispatcherEndpointRegistry: Endpoint registration and discovery """ -import json -import socket +import httpx -from tests.lua.conftest import BUFFER_SIZE, api +from tests.lua.conftest import api # Request ID counter for malformed request tests only _test_request_id = 0 @@ -27,18 +26,16 @@ class TestDispatcherProtocolValidation: - Endpoint exists in registry """ - def test_missing_name_field(self, client: socket.socket) -> None: + 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 - # Manually construct malformed request (missing 'method' field) - request = ( - json.dumps({"jsonrpc": "2.0", "params": {}, "id": _test_request_id}) + "\n" + # Send JSON-RPC request missing 'method' field + response = client.post( + "/", + json={"jsonrpc": "2.0", "params": {}, "id": _test_request_id}, ) - client.send(request.encode()) - - response = client.recv(BUFFER_SIZE).decode().strip() - parsed = json.loads(response) + parsed = response.json() assert "error" in parsed assert "message" in parsed["error"] @@ -47,45 +44,42 @@ def test_missing_name_field(self, client: socket.socket) -> None: assert parsed["error"]["data"]["name"] == "BAD_REQUEST" assert "method" in parsed["error"]["message"].lower() - def test_invalid_name_type(self, client: socket.socket) -> None: + 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 - # Manually construct malformed request ('method' is not a string) - request = ( - json.dumps( - {"jsonrpc": "2.0", "method": 123, "params": {}, "id": _test_request_id} - ) - + "\n" + # Send JSON-RPC request with 'method' as integer + response = client.post( + "/", + json={ + "jsonrpc": "2.0", + "method": 123, + "params": {}, + "id": _test_request_id, + }, ) - client.send(request.encode()) - - response = client.recv(BUFFER_SIZE).decode().strip() - parsed = json.loads(response) + parsed = response.json() assert "error" in parsed assert parsed["error"]["data"]["name"] == "BAD_REQUEST" - def test_missing_arguments_field(self, client: socket.socket) -> None: + 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 - # Manually construct request without 'params' field - request = ( - json.dumps({"jsonrpc": "2.0", "method": "health", "id": _test_request_id}) - + "\n" + # Send JSON-RPC request without 'params' field + response = client.post( + "/", + json={"jsonrpc": "2.0", "method": "health", "id": _test_request_id}, ) - client.send(request.encode()) - - response = client.recv(BUFFER_SIZE).decode().strip() - parsed = json.loads(response) + 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: socket.socket) -> None: + def test_unknown_endpoint(self, client: httpx.Client) -> None: """Test that unknown endpoints are rejected.""" response = api(client, "nonexistent_endpoint", {}) @@ -93,7 +87,7 @@ def test_unknown_endpoint(self, client: socket.socket) -> None: assert response["error"]["data"]["name"] == "BAD_REQUEST" assert "nonexistent_endpoint" in response["error"]["message"] - def test_valid_health_endpoint_request(self, client: socket.socket) -> None: + def test_valid_health_endpoint_request(self, client: httpx.Client) -> None: """Test that valid requests to health endpoint succeed.""" response = api(client, "health", {}) @@ -110,7 +104,7 @@ class TestDispatcherSchemaValidation: endpoint schemas using the Validator module. """ - def test_missing_required_field(self, client: socket.socket) -> None: + 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( @@ -127,9 +121,7 @@ def test_missing_required_field(self, client: socket.socket) -> None: 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: socket.socket - ) -> None: + def test_invalid_type_string_instead_of_integer(self, client: httpx.Client) -> None: """Test that type validation rejects wrong types.""" response = api( client, @@ -145,7 +137,7 @@ def test_invalid_type_string_instead_of_integer( assert response["error"]["data"]["name"] == "BAD_REQUEST" assert "required_integer" in response["error"]["message"].lower() - def test_array_item_type_validation(self, client: socket.socket) -> None: + def test_array_item_type_validation(self, client: httpx.Client) -> None: """Test that array items are validated for correct type.""" response = api( client, @@ -165,7 +157,7 @@ def test_array_item_type_validation(self, client: socket.socket) -> None: assert "error" in response assert response["error"]["data"]["name"] == "BAD_REQUEST" - def test_valid_request_with_all_fields(self, client: socket.socket) -> None: + def test_valid_request_with_all_fields(self, client: httpx.Client) -> None: """Test that valid requests with multiple fields pass validation.""" response = api( client, @@ -186,7 +178,7 @@ def test_valid_request_with_all_fields(self, client: socket.socket) -> None: assert "received_args" in response["result"] def test_valid_request_with_only_required_fields( - self, client: socket.socket + self, client: httpx.Client ) -> None: """Test that valid requests with only required fields pass validation.""" response = api( @@ -211,7 +203,7 @@ class TestDispatcherStateValidation: Note: These tests may pass or fail depending on current game state. """ - def test_state_validation_enforcement(self, client: socket.socket) -> None: + 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", {}) @@ -234,7 +226,7 @@ class TestDispatcherExecution: handles runtime errors with appropriate error codes. """ - def test_successful_endpoint_execution(self, client: socket.socket) -> None: + def test_successful_endpoint_execution(self, client: httpx.Client) -> None: """Test that endpoints execute successfully with valid input.""" response = api( client, @@ -252,7 +244,7 @@ def test_successful_endpoint_execution(self, client: socket.socket) -> None: assert "received_args" in response["result"] assert response["result"]["received_args"]["required_integer"] == 42 - def test_execution_error_handling(self, client: socket.socket) -> None: + 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"}) @@ -260,14 +252,14 @@ def test_execution_error_handling(self, client: socket.socket) -> None: assert response["error"]["data"]["name"] == "INTERNAL_ERROR" assert "Intentional test error" in response["error"]["message"] - def test_execution_error_no_categorization(self, client: socket.socket) -> None: + 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: socket.socket) -> None: + 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"}) @@ -279,7 +271,7 @@ def test_execution_success_when_no_error(self, client: socket.socket) -> None: class TestDispatcherEndpointRegistry: """Tests for endpoint registration and discovery.""" - def test_health_endpoint_is_registered(self, client: socket.socket) -> None: + def test_health_endpoint_is_registered(self, client: httpx.Client) -> None: """Test that the health endpoint is properly registered.""" response = api(client, "health", {}) @@ -288,7 +280,7 @@ def test_health_endpoint_is_registered(self, client: socket.socket) -> None: assert response["result"]["status"] == "ok" def test_multiple_sequential_requests_to_same_endpoint( - self, client: socket.socket + self, client: httpx.Client ) -> None: """Test that multiple requests to the same endpoint work.""" for i in range(3): @@ -298,7 +290,7 @@ def test_multiple_sequential_requests_to_same_endpoint( assert "status" in response["result"] assert response["result"]["status"] == "ok" - def test_requests_to_different_endpoints(self, client: socket.socket) -> None: + 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", {}) diff --git a/tests/lua/core/test_server.py b/tests/lua/core/test_server.py index 77a1e2c..12cab4b 100644 --- a/tests/lua/core/test_server.py +++ b/tests/lua/core/test_server.py @@ -1,344 +1,493 @@ """ -Integration tests for BB_SERVER TCP communication (JSON-RPC 2.0). - -Test classes are organized by BB_SERVER function: -- TestBBServerInit: BB_SERVER.init() - server initialization and port binding -- TestBBServerAccept: BB_SERVER.accept() - client connection handling -- TestBBServerReceive: BB_SERVER.receive() - protocol enforcement and parsing -- TestBBServerSendResponse: BB_SERVER.send_response() - response sending +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 json import socket -import time +import httpx import pytest -from tests.lua.conftest import BUFFER_SIZE - -# Request ID counter for JSON-RPC 2.0 -_test_request_id = 0 - - -def make_request(method: str, params: dict = {}) -> str: - """Create a JSON-RPC 2.0 request string.""" - global _test_request_id - _test_request_id += 1 - return ( - json.dumps( - { - "jsonrpc": "2.0", - "method": method, - "params": params, - "id": _test_request_id, - } - ) - + "\n" - ) - -class TestBBServerInit: - """Tests for BB_SERVER.init() - server initialization and port binding.""" +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.""" - sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - sock.settimeout(2) - try: + 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}" - finally: - sock.close() def test_port_is_exclusively_bound(self, port: int) -> None: """Test that server exclusively binds the port.""" - sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - try: + 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 - finally: - sock.close() - - def test_port_not_reusable_while_running(self, port: int) -> None: - """Test that port cannot be reused while server is running.""" - sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - try: - with pytest.raises(OSError) as exc_info: - sock.bind(("127.0.0.1", port)) - sock.listen(1) - assert exc_info.value.errno == errno.EADDRINUSE - finally: - sock.close() + 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 TestBBServerAccept: - """Tests for BB_SERVER.accept() - client connection handling.""" - def test_accepts_connections(self, client: socket.socket) -> None: - """Test that server accepts client connections.""" - assert client.fileno() != -1, "Client should connect successfully" +class TestHTTPServerRouting: + """Tests for HTTP request routing.""" - def test_sequential_connections(self, port: int) -> None: - """Test that server handles sequential connections correctly.""" - for i in range(3): - time.sleep(0.02) # Delay to prevent overwhelming server - sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - sock.settimeout(2) - try: - sock.connect(("127.0.0.1", port)) - assert sock.fileno() != -1, f"Connection {i + 1} should succeed" - finally: - sock.close() - - def test_rapid_sequential_connections(self, port: int) -> None: - """Test server handles rapid sequential connections.""" - for i in range(5): - time.sleep(0.02) # Delay to prevent overwhelming server - sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - sock.settimeout(2) - try: - sock.connect(("127.0.0.1", port)) - assert sock.fileno() != -1, f"Rapid connection {i + 1} should succeed" - finally: - sock.close() - - def test_immediate_disconnect(self, port: int) -> None: - """Test server handles clients that disconnect immediately.""" - sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - sock.settimeout(2) - sock.connect(("127.0.0.1", port)) - sock.close() - - time.sleep(0.1) # Delay to prevent overwhelming server - - # Server should still accept new connections - sock2 = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - sock2.settimeout(2) - try: - sock2.connect(("127.0.0.1", port)) - assert sock2.fileno() != -1, ( - "Server should accept connection after disconnect" - ) - finally: - sock2.close() - - def test_reconnect_after_graceful_disconnect(self, port: int) -> None: - """Test client can reconnect after clean disconnect.""" - # First connection - sock1 = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - sock1.settimeout(2) - sock1.connect(("127.0.0.1", port)) - - # Send a JSON-RPC 2.0 request - msg = make_request("health", {}) - sock1.send(msg.encode()) - sock1.recv(BUFFER_SIZE) # Consume response - - # Close connection - sock1.close() - - # Reconnect - sock2 = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - sock2.settimeout(2) - try: - sock2.connect(("127.0.0.1", port)) - assert sock2.fileno() != -1, "Should reconnect successfully" - - # Verify new connection works - sock2.send(make_request("health", {}).encode()) - response = sock2.recv(BUFFER_SIZE) - assert len(response) > 0, "Should receive response after reconnect" - finally: - sock2.close() - - def test_client_disconnect_without_sending(self, port: int) -> None: - """Test server handles client that connects but never sends data.""" - sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - sock.settimeout(2) - sock.connect(("127.0.0.1", port)) - sock.close() - - time.sleep(0.1) # Delay to prevent overwhelming server - - # Server should still accept new connections - sock2 = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - sock2.settimeout(2) - try: - sock2.connect(("127.0.0.1", port)) - assert sock2.fileno() != -1 - finally: - sock2.close() - - -class TestBBServerReceive: - """Tests for BB_SERVER.receive() - protocol enforcement and parsing. - - Tests verify error responses for protocol violations: - - Message size limit (256 bytes including newline) - - Pipelining rejection (multiple messages) - - JSON validation (must be object, not string/number/array) - - Invalid JSON syntax - - Edge cases (whitespace, nested objects, escaped characters) - """ - - def test_message_too_large(self, client: socket.socket) -> None: - """Test that messages exceeding 256 bytes are rejected.""" - # Create message > 255 bytes (line + newline must be <= 256) - large_msg = { - "jsonrpc": "2.0", - "method": "test", - "params": {"data": "x" * 300}, - "id": 1, - } - msg = json.dumps(large_msg) + "\n" - assert len(msg) > 256, "Test message should exceed 256 bytes" - - client.send(msg.encode()) - - response = client.recv(BUFFER_SIZE).decode().strip() - raw_data = json.loads(response) - - # Response is JSON-RPC 2.0 error format - assert "error" in raw_data - assert raw_data["error"]["data"]["name"] == "BAD_REQUEST" - assert "too large" in raw_data["error"]["message"].lower() - - def test_pipelined_messages_rejected(self, client: socket.socket) -> None: - """Test that sending multiple messages at once are processed sequentially.""" - msg1 = make_request("health", {}) - msg2 = make_request("health", {}) - - # Send both messages in one packet (pipelining) - client.send((msg1 + msg2).encode()) - - # Server processes messages sequentially - we should get two responses - response = client.recv(BUFFER_SIZE).decode().strip() - - # We may get one or both responses depending on timing - # The important thing is no error occurred - lines = response.split("\n") - raw_data1 = json.loads(lines[0]) - - # First response should be successful - assert "result" in raw_data1 - assert "status" in raw_data1["result"] - assert raw_data1["result"]["status"] == "ok" - - # If we got both in one recv, verify second is also good - if len(lines) > 1 and lines[1]: - raw_data2 = json.loads(lines[1]) - assert "result" in raw_data2 - assert "status" in raw_data2["result"] - assert raw_data2["result"]["status"] == "ok" - - def test_invalid_json_syntax(self, client: socket.socket) -> None: - """Test that malformed JSON is rejected.""" - client.send(b"{invalid json}\n") - - response = client.recv(BUFFER_SIZE).decode().strip() - raw_data = json.loads(response) - - assert "error" in raw_data - assert raw_data["error"]["data"]["name"] == "BAD_REQUEST" - - def test_json_string_rejected(self, client: socket.socket) -> None: - """Test that JSON strings are rejected (must be object).""" - client.send(b'"just a string"\n') - - response = client.recv(BUFFER_SIZE).decode().strip() - raw_data = json.loads(response) - - assert "error" in raw_data - assert raw_data["error"]["data"]["name"] == "BAD_REQUEST" - - def test_json_number_rejected(self, client: socket.socket) -> None: - """Test that JSON numbers are rejected (must be object).""" - client.send(b"42\n") - - response = client.recv(BUFFER_SIZE).decode().strip() - raw_data = json.loads(response) - - assert "error" in raw_data - assert raw_data["error"]["data"]["name"] == "BAD_REQUEST" - - def test_json_array_rejected(self, client: socket.socket) -> None: - """Test that JSON arrays are rejected (must be object starting with '{').""" - client.send(b'["array", "of", "values"]\n') - - response = client.recv(BUFFER_SIZE).decode().strip() - raw_data = json.loads(response) - - assert "error" in raw_data - assert raw_data["error"]["data"]["name"] == "BAD_REQUEST" - - def test_only_whitespace_line_rejected(self, client: socket.socket) -> None: - """Test that whitespace-only lines are rejected as invalid JSON.""" - # Send whitespace-only line (gets trimmed to empty string, fails '{' check) - client.send(b" \t \n") - - response = client.recv(BUFFER_SIZE).decode().strip() - raw_data = json.loads(response) - - # Should be rejected as invalid JSON (trimmed to empty, doesn't start with '{') - assert "error" in raw_data - assert raw_data["error"]["data"]["name"] == "BAD_REQUEST" - - -class TestBBServerSendResponse: - """Tests for BB_SERVER.send_response() and send_error() - response sending.""" - - def test_server_accepts_data(self, client: socket.socket) -> None: - """Test that server accepts data from connected clients.""" - test_data = b"test\n" - bytes_sent = client.send(test_data) - assert bytes_sent == len(test_data), "Should send all data" + 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"] == "" - def test_multiple_sequential_valid_requests(self, client: socket.socket) -> None: - """Test handling multiple valid requests sent sequentially (not pipelined).""" - # Send first request - msg1 = make_request("health", {}) - client.send(msg1.encode()) - - response1 = client.recv(BUFFER_SIZE).decode().strip() - raw_data1 = json.loads(response1) - assert "result" in raw_data1 - assert "status" in raw_data1["result"] # Health endpoint returns status - - # Send second request on same connection - msg2 = make_request("health", {}) - client.send(msg2.encode()) - response2 = client.recv(BUFFER_SIZE).decode().strip() - raw_data2 = json.loads(response2) - assert "result" in raw_data2 - assert "status" in raw_data2["result"] - - def test_whitespace_around_json_accepted(self, client: socket.socket) -> None: - """Test that JSON with leading/trailing whitespace is accepted.""" - global _test_request_id - _test_request_id += 1 - msg = ( - " " - + json.dumps( - { +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": _test_request_id, - } + "id": i, + }, ) - + " \n" + 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, + }, ) - client.send(msg.encode()) - response = client.recv(BUFFER_SIZE).decode().strip() - raw_data = json.loads(response) - - # Should be processed successfully (whitespace trimmed at line 134) - # Result should contain health status or error - if "result" in raw_data: - assert "status" in raw_data["result"] - else: - assert "error" in raw_data + assert response4.status_code == 200 diff --git a/tests/lua/core/test_validator.py b/tests/lua/core/test_validator.py index d50ffd6..2143994 100644 --- a/tests/lua/core/test_validator.py +++ b/tests/lua/core/test_validator.py @@ -7,7 +7,7 @@ # - Array item type validation (integer arrays only) # - Error codes and messages -import socket +import httpx from tests.lua.conftest import ( api, @@ -23,7 +23,7 @@ class TestTypeValidation: """Test type validation for all supported types.""" - def test_valid_string_type(self, client: socket.socket) -> None: + def test_valid_string_type(self, client: httpx.Client) -> None: """Test that valid string type passes validation.""" response = api( client, @@ -35,7 +35,7 @@ def test_valid_string_type(self, client: socket.socket) -> None: ) assert_test_response(response) - def test_invalid_string_type(self, client: socket.socket) -> None: + def test_invalid_string_type(self, client: httpx.Client) -> None: """Test that invalid string type fails validation.""" response = api( client, @@ -51,7 +51,7 @@ def test_invalid_string_type(self, client: socket.socket) -> None: "string_field", ) - def test_valid_integer_type(self, client: socket.socket) -> None: + def test_valid_integer_type(self, client: httpx.Client) -> None: """Test that valid integer type passes validation.""" response = api( client, @@ -63,7 +63,7 @@ def test_valid_integer_type(self, client: socket.socket) -> None: ) assert_test_response(response) - def test_invalid_integer_type_float(self, client: socket.socket) -> None: + def test_invalid_integer_type_float(self, client: httpx.Client) -> None: """Test that float fails integer validation.""" response = api( client, @@ -79,7 +79,7 @@ def test_invalid_integer_type_float(self, client: socket.socket) -> None: "integer_field", ) - def test_invalid_integer_type_string(self, client: socket.socket) -> None: + def test_invalid_integer_type_string(self, client: httpx.Client) -> None: """Test that string fails integer validation.""" response = api( client, @@ -95,7 +95,7 @@ def test_invalid_integer_type_string(self, client: socket.socket) -> None: "integer_field", ) - def test_valid_array_type(self, client: socket.socket) -> None: + def test_valid_array_type(self, client: httpx.Client) -> None: """Test that valid array type passes validation.""" response = api( client, @@ -107,7 +107,7 @@ def test_valid_array_type(self, client: socket.socket) -> None: ) assert_test_response(response) - def test_invalid_array_type_not_sequential(self, client: socket.socket) -> None: + def test_invalid_array_type_not_sequential(self, client: httpx.Client) -> None: """Test that non-sequential table fails array validation.""" response = api( client, @@ -123,7 +123,7 @@ def test_invalid_array_type_not_sequential(self, client: socket.socket) -> None: "array_field", ) - def test_invalid_array_type_string(self, client: socket.socket) -> None: + def test_invalid_array_type_string(self, client: httpx.Client) -> None: """Test that string fails array validation.""" response = api( client, @@ -139,7 +139,7 @@ def test_invalid_array_type_string(self, client: socket.socket) -> None: "array_field", ) - def test_valid_boolean_type_true(self, client: socket.socket) -> None: + def test_valid_boolean_type_true(self, client: httpx.Client) -> None: """Test that boolean true passes validation.""" response = api( client, @@ -151,7 +151,7 @@ def test_valid_boolean_type_true(self, client: socket.socket) -> None: ) assert_test_response(response) - def test_valid_boolean_type_false(self, client: socket.socket) -> None: + def test_valid_boolean_type_false(self, client: httpx.Client) -> None: """Test that boolean false passes validation.""" response = api( client, @@ -163,7 +163,7 @@ def test_valid_boolean_type_false(self, client: socket.socket) -> None: ) assert_test_response(response) - def test_invalid_boolean_type_string(self, client: socket.socket) -> None: + def test_invalid_boolean_type_string(self, client: httpx.Client) -> None: """Test that string fails boolean validation.""" response = api( client, @@ -179,7 +179,7 @@ def test_invalid_boolean_type_string(self, client: socket.socket) -> None: "boolean_field", ) - def test_invalid_boolean_type_number(self, client: socket.socket) -> None: + def test_invalid_boolean_type_number(self, client: httpx.Client) -> None: """Test that number fails boolean validation.""" response = api( client, @@ -195,7 +195,7 @@ def test_invalid_boolean_type_number(self, client: socket.socket) -> None: "boolean_field", ) - def test_valid_table_type(self, client: socket.socket) -> None: + def test_valid_table_type(self, client: httpx.Client) -> None: """Test that valid table (non-array) passes validation.""" response = api( client, @@ -207,7 +207,7 @@ def test_valid_table_type(self, client: socket.socket) -> None: ) assert_test_response(response) - def test_valid_table_type_empty(self, client: socket.socket) -> None: + def test_valid_table_type_empty(self, client: httpx.Client) -> None: """Test that empty table passes validation.""" response = api( client, @@ -219,7 +219,7 @@ def test_valid_table_type_empty(self, client: socket.socket) -> None: ) assert_test_response(response) - def test_invalid_table_type_array(self, client: socket.socket) -> None: + 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, @@ -235,7 +235,7 @@ def test_invalid_table_type_array(self, client: socket.socket) -> None: "table_field", ) - def test_invalid_table_type_string(self, client: socket.socket) -> None: + def test_invalid_table_type_string(self, client: httpx.Client) -> None: """Test that string fails table validation.""" response = api( client, @@ -260,7 +260,7 @@ def test_invalid_table_type_string(self, client: socket.socket) -> None: class TestRequiredFields: """Test required field validation.""" - def test_required_field_present(self, client: socket.socket) -> None: + def test_required_field_present(self, client: httpx.Client) -> None: """Test that request with required field passes.""" response = api( client, @@ -269,7 +269,7 @@ def test_required_field_present(self, client: socket.socket) -> None: ) assert_test_response(response) - def test_required_field_missing(self, client: socket.socket) -> None: + def test_required_field_missing(self, client: httpx.Client) -> None: """Test that request without required field fails.""" response = api( client, @@ -282,7 +282,7 @@ def test_required_field_missing(self, client: socket.socket) -> None: "required_field", ) - def test_optional_field_missing(self, client: socket.socket) -> None: + def test_optional_field_missing(self, client: httpx.Client) -> None: """Test that missing optional fields are allowed.""" response = api( client, @@ -303,7 +303,7 @@ def test_optional_field_missing(self, client: socket.socket) -> None: class TestArrayItemTypes: """Test array item type validation.""" - def test_array_of_integers_valid(self, client: socket.socket) -> None: + def test_array_of_integers_valid(self, client: httpx.Client) -> None: """Test that array of integers passes.""" response = api( client, @@ -315,7 +315,7 @@ def test_array_of_integers_valid(self, client: socket.socket) -> None: ) assert_test_response(response) - def test_array_of_integers_invalid_float(self, client: socket.socket) -> None: + def test_array_of_integers_invalid_float(self, client: httpx.Client) -> None: """Test that array with float items fails integer validation.""" response = api( client, @@ -331,7 +331,7 @@ def test_array_of_integers_invalid_float(self, client: socket.socket) -> None: "array_of_integers", ) - def test_array_of_integers_invalid_string(self, client: socket.socket) -> None: + def test_array_of_integers_invalid_string(self, client: httpx.Client) -> None: """Test that array with string items fails integer validation.""" response = api( client, @@ -356,7 +356,7 @@ def test_array_of_integers_invalid_string(self, client: socket.socket) -> None: class TestFailFastBehavior: """Test that validator fails fast on first error.""" - def test_multiple_errors_returns_first(self, client: socket.socket) -> None: + 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, @@ -386,7 +386,7 @@ class TestEdgeCases: """Test edge cases and boundary conditions.""" def test_empty_arguments_with_only_required_field( - self, client: socket.socket + self, client: httpx.Client ) -> None: """Test that arguments with only required field passes.""" response = api( @@ -396,7 +396,7 @@ def test_empty_arguments_with_only_required_field( ) assert_test_response(response) - def test_all_fields_provided(self, client: socket.socket) -> None: + def test_all_fields_provided(self, client: httpx.Client) -> None: """Test request with multiple valid fields.""" response = api( client, @@ -413,7 +413,7 @@ def test_all_fields_provided(self, client: socket.socket) -> None: ) assert_test_response(response) - def test_empty_array_when_allowed(self, client: socket.socket) -> None: + def test_empty_array_when_allowed(self, client: httpx.Client) -> None: """Test that empty array passes when no min constraint.""" response = api( client, @@ -425,7 +425,7 @@ def test_empty_array_when_allowed(self, client: socket.socket) -> None: ) assert_test_response(response) - def test_empty_string_when_allowed(self, client: socket.socket) -> None: + def test_empty_string_when_allowed(self, client: httpx.Client) -> None: """Test that empty string passes when no min constraint.""" response = api( client, From 3849203832b1eaa029f959e555deb0c2c6408b34 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Sun, 7 Dec 2025 20:10:00 +0100 Subject: [PATCH 198/230] test(lua.endpoints): update test endpoints to use httpx client --- tests/lua/endpoints/test_add.py | 83 ++++++++++++-------------- tests/lua/endpoints/test_buy.py | 37 ++++++------ tests/lua/endpoints/test_cash_out.py | 6 +- tests/lua/endpoints/test_discard.py | 20 +++---- tests/lua/endpoints/test_gamestate.py | 6 +- tests/lua/endpoints/test_health.py | 6 +- tests/lua/endpoints/test_load.py | 11 ++-- tests/lua/endpoints/test_menu.py | 6 +- tests/lua/endpoints/test_next_round.py | 6 +- tests/lua/endpoints/test_play.py | 22 +++---- tests/lua/endpoints/test_rearrange.py | 32 +++++----- tests/lua/endpoints/test_reroll.py | 8 +-- tests/lua/endpoints/test_save.py | 15 +++-- tests/lua/endpoints/test_screenshot.py | 11 ++-- tests/lua/endpoints/test_select.py | 10 ++-- tests/lua/endpoints/test_sell.py | 30 +++++----- tests/lua/endpoints/test_set.py | 50 ++++++++-------- tests/lua/endpoints/test_skip.py | 10 ++-- tests/lua/endpoints/test_start.py | 18 +++--- tests/lua/endpoints/test_use.py | 50 ++++++++-------- 20 files changed, 215 insertions(+), 222 deletions(-) diff --git a/tests/lua/endpoints/test_add.py b/tests/lua/endpoints/test_add.py index 21395ba..66b5d23 100644 --- a/tests/lua/endpoints/test_add.py +++ b/tests/lua/endpoints/test_add.py @@ -1,7 +1,6 @@ """Tests for src/lua/endpoints/add.lua""" -import socket - +import httpx import pytest from tests.lua.conftest import ( @@ -15,7 +14,7 @@ class TestAddEndpoint: """Test basic add endpoint functionality.""" - def test_add_joker(self, client: socket.socket) -> None: + def test_add_joker(self, client: httpx.Client) -> None: """Test adding a joker with valid key.""" gamestate = load_fixture( client, @@ -29,7 +28,7 @@ def test_add_joker(self, client: socket.socket) -> None: assert after["jokers"]["count"] == 1 assert after["jokers"]["cards"][0]["key"] == "j_joker" - def test_add_consumable_tarot(self, client: socket.socket) -> None: + def test_add_consumable_tarot(self, client: httpx.Client) -> None: """Test adding a tarot consumable with valid key.""" gamestate = load_fixture( client, @@ -43,7 +42,7 @@ def test_add_consumable_tarot(self, client: socket.socket) -> None: assert after["consumables"]["count"] == 1 assert after["consumables"]["cards"][0]["key"] == "c_fool" - def test_add_consumable_planet(self, client: socket.socket) -> None: + def test_add_consumable_planet(self, client: httpx.Client) -> None: """Test adding a planet consumable with valid key.""" gamestate = load_fixture( client, @@ -57,7 +56,7 @@ def test_add_consumable_planet(self, client: socket.socket) -> None: assert after["consumables"]["count"] == 1 assert after["consumables"]["cards"][0]["key"] == "c_mercury" - def test_add_consumable_spectral(self, client: socket.socket) -> None: + def test_add_consumable_spectral(self, client: httpx.Client) -> None: """Test adding a spectral consumable with valid key.""" gamestate = load_fixture( client, @@ -71,7 +70,7 @@ def test_add_consumable_spectral(self, client: socket.socket) -> None: assert after["consumables"]["count"] == 1 assert after["consumables"]["cards"][0]["key"] == "c_familiar" - def test_add_voucher(self, client: socket.socket) -> None: + def test_add_voucher(self, client: httpx.Client) -> None: """Test adding a voucher with valid key in SHOP state.""" gamestate = load_fixture( client, @@ -85,7 +84,7 @@ def test_add_voucher(self, client: socket.socket) -> None: assert after["vouchers"]["count"] == 1 assert after["vouchers"]["cards"][0]["key"] == "v_overstock_norm" - def test_add_playing_card(self, client: socket.socket) -> None: + def test_add_playing_card(self, client: httpx.Client) -> None: """Test adding a playing card with valid key.""" gamestate = load_fixture( client, @@ -99,7 +98,7 @@ def test_add_playing_card(self, client: socket.socket) -> None: assert after["hand"]["count"] == 9 assert after["hand"]["cards"][8]["key"] == "H_A" - def test_add_no_key_provided(self, client: socket.socket) -> None: + def test_add_no_key_provided(self, client: httpx.Client) -> None: """Test add endpoint with no key parameter.""" gamestate = load_fixture( client, @@ -117,7 +116,7 @@ def test_add_no_key_provided(self, client: socket.socket) -> None: class TestAddEndpointValidation: """Test add endpoint parameter validation.""" - def test_invalid_key_type_number(self, client: socket.socket) -> None: + 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, @@ -131,7 +130,7 @@ def test_invalid_key_type_number(self, client: socket.socket) -> None: "Field 'key' must be of type string", ) - def test_invalid_key_unknown_format(self, client: socket.socket) -> None: + 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, @@ -145,7 +144,7 @@ def test_invalid_key_unknown_format(self, client: socket.socket) -> None: "Invalid card key format. Expected: joker (j_*), consumable (c_*), voucher (v_*), or playing card (SUIT_RANK)", ) - def test_invalid_key_known_format(self, client: socket.socket) -> None: + def test_invalid_key_known_format(self, client: httpx.Client) -> None: """Test that add fails when key has known format.""" gamestate = load_fixture( client, @@ -163,7 +162,7 @@ def test_invalid_key_known_format(self, client: socket.socket) -> None: class TestAddEndpointStateRequirements: """Test add endpoint state requirements.""" - def test_add_from_BLIND_SELECT(self, client: socket.socket) -> None: + 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" @@ -173,7 +172,7 @@ def test_add_from_BLIND_SELECT(self, client: socket.socket) -> None: "Method 'add' requires one of these states: SELECTING_HAND, SHOP, ROUND_EVAL", ) - def test_add_playing_card_from_SHOP(self, client: socket.socket) -> None: + 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, @@ -187,7 +186,7 @@ def test_add_playing_card_from_SHOP(self, client: socket.socket) -> None: "Playing cards can only be added in SELECTING_HAND state", ) - def test_add_voucher_card_from_SELECTING_HAND(self, client: socket.socket) -> None: + 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, @@ -206,7 +205,7 @@ 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: socket.socket, seal: str) -> None: + 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, @@ -221,7 +220,7 @@ def test_add_playing_card_with_seal(self, client: socket.socket, seal: str) -> N 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: socket.socket) -> None: + 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, @@ -239,7 +238,7 @@ def test_add_playing_card_invalid_seal(self, client: socket.socket) -> None: @pytest.mark.parametrize("key", ["j_joker", "c_fool", "v_overstock_norm"]) def test_add_non_playing_card_with_seal_fails( - self, client: socket.socket, key: str + self, client: httpx.Client, key: str ) -> None: """Test that adding non-playing cards with seal parameter fails.""" gamestate = load_fixture( @@ -260,7 +259,7 @@ class TestAddEndpointEdition: """Test edition parameter for add endpoint.""" @pytest.mark.parametrize("edition", ["HOLO", "FOIL", "POLYCHROME", "NEGATIVE"]) - def test_add_joker_with_edition(self, client: socket.socket, edition: str) -> None: + def test_add_joker_with_edition(self, client: httpx.Client, edition: str) -> None: """Test adding a joker with various editions.""" gamestate = load_fixture( client, @@ -277,7 +276,7 @@ def test_add_joker_with_edition(self, client: socket.socket, edition: str) -> No @pytest.mark.parametrize("edition", ["HOLO", "FOIL", "POLYCHROME", "NEGATIVE"]) def test_add_playing_card_with_edition( - self, client: socket.socket, edition: str + self, client: httpx.Client, edition: str ) -> None: """Test adding a playing card with various editions.""" gamestate = load_fixture( @@ -293,7 +292,7 @@ def test_add_playing_card_with_edition( 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: socket.socket) -> None: + 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, @@ -310,7 +309,7 @@ def test_add_consumable_with_negative_edition(self, client: socket.socket) -> No @pytest.mark.parametrize("edition", ["HOLO", "FOIL", "POLYCHROME"]) def test_add_consumable_with_non_negative_edition_fails( - self, client: socket.socket, edition: str + self, client: httpx.Client, edition: str ) -> None: """Test that adding a consumable with HOLO | FOIL | POLYCHROME edition fails.""" gamestate = load_fixture( @@ -327,7 +326,7 @@ def test_add_consumable_with_non_negative_edition_fails( "Consumables can only have NEGATIVE edition", ) - def test_add_voucher_with_edition_fails(self, client: socket.socket) -> None: + 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, @@ -341,7 +340,7 @@ def test_add_voucher_with_edition_fails(self, client: socket.socket) -> None: response, "BAD_REQUEST", "Edition cannot be applied to vouchers" ) - def test_add_playing_card_invalid_edition(self, client: socket.socket) -> None: + 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, @@ -366,7 +365,7 @@ class TestAddEndpointEnhancement: ["BONUS", "MULT", "WILD", "GLASS", "STEEL", "STONE", "GOLD", "LUCKY"], ) def test_add_playing_card_with_enhancement( - self, client: socket.socket, enhancement: str + self, client: httpx.Client, enhancement: str ) -> None: """Test adding a playing card with various enhancements.""" gamestate = load_fixture( @@ -382,7 +381,7 @@ def test_add_playing_card_with_enhancement( 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: socket.socket) -> None: + 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, @@ -400,7 +399,7 @@ def test_add_playing_card_invalid_enhancement(self, client: socket.socket) -> No @pytest.mark.parametrize("key", ["j_joker", "c_fool", "v_overstock_norm"]) def test_add_non_playing_card_with_enhancement_fails( - self, client: socket.socket, key: str + self, client: httpx.Client, key: str ) -> None: """Test that adding non-playing cards with enhancement parameter fails.""" gamestate = load_fixture( @@ -421,7 +420,7 @@ def test_add_non_playing_card_with_enhancement_fails( class TestAddEndpointStickers: """Test sticker parameters (eternal, perishable) for add endpoint.""" - def test_add_joker_with_eternal(self, client: socket.socket) -> None: + def test_add_joker_with_eternal(self, client: httpx.Client) -> None: """Test adding an eternal joker.""" gamestate = load_fixture( client, @@ -438,7 +437,7 @@ def test_add_joker_with_eternal(self, client: socket.socket) -> None: @pytest.mark.parametrize("key", ["c_fool", "v_overstock_norm"]) def test_add_non_joker_with_eternal_fails( - self, client: socket.socket, key: str + self, client: httpx.Client, key: str ) -> None: """Test that adding non-joker cards with eternal parameter fails.""" gamestate = load_fixture( @@ -454,7 +453,7 @@ def test_add_non_joker_with_eternal_fails( "Eternal can only be applied to jokers", ) - def test_add_playing_card_with_eternal_fails(self, client: socket.socket) -> None: + 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, @@ -470,9 +469,7 @@ def test_add_playing_card_with_eternal_fails(self, client: socket.socket) -> Non ) @pytest.mark.parametrize("rounds", [1, 5, 10]) - def test_add_joker_with_perishable( - self, client: socket.socket, rounds: int - ) -> None: + 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, @@ -487,7 +484,7 @@ def test_add_joker_with_perishable( 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: socket.socket) -> None: + 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, @@ -507,7 +504,7 @@ def test_add_joker_with_eternal_and_perishable(self, client: socket.socket) -> N @pytest.mark.parametrize("invalid_value", [0, -1]) def test_add_joker_with_perishable_invalid_integer_fails( - self, client: socket.socket, invalid_value: int + self, client: httpx.Client, invalid_value: int ) -> None: """Test that invalid perishable values (zero, negative, float) are rejected.""" gamestate = load_fixture( @@ -526,7 +523,7 @@ def test_add_joker_with_perishable_invalid_integer_fails( @pytest.mark.parametrize("invalid_value", [1.5, "NOT_INT_1"]) def test_add_joker_with_perishable_invalid_type_fails( - self, client: socket.socket, invalid_value: float | str + self, client: httpx.Client, invalid_value: float | str ) -> None: """Test that perishable with string value is rejected.""" gamestate = load_fixture( @@ -545,7 +542,7 @@ def test_add_joker_with_perishable_invalid_type_fails( @pytest.mark.parametrize("key", ["c_fool", "v_overstock_norm"]) def test_add_non_joker_with_perishable_fails( - self, client: socket.socket, key: str + self, client: httpx.Client, key: str ) -> None: """Test that adding non-joker cards with perishable parameter fails.""" gamestate = load_fixture( @@ -561,9 +558,7 @@ def test_add_non_joker_with_perishable_fails( "Perishable can only be applied to jokers", ) - def test_add_playing_card_with_perishable_fails( - self, client: socket.socket - ) -> None: + 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, @@ -579,7 +574,7 @@ def test_add_playing_card_with_perishable_fails( "Perishable can only be applied to jokers", ) - def test_add_joker_with_rental(self, client: socket.socket) -> None: + def test_add_joker_with_rental(self, client: httpx.Client) -> None: """Test adding a rental joker.""" gamestate = load_fixture( client, @@ -596,7 +591,7 @@ def test_add_joker_with_rental(self, client: socket.socket) -> None: @pytest.mark.parametrize("key", ["c_fool", "v_overstock_norm"]) def test_add_non_joker_with_rental_fails( - self, client: socket.socket, key: str + self, client: httpx.Client, key: str ) -> None: """Test that rental can only be applied to jokers.""" gamestate = load_fixture( @@ -612,7 +607,7 @@ def test_add_non_joker_with_rental_fails( "Rental can only be applied to jokers", ) - def test_add_joker_with_rental_and_eternal(self, client: socket.socket) -> None: + 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, @@ -630,7 +625,7 @@ def test_add_joker_with_rental_and_eternal(self, client: socket.socket) -> None: 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: socket.socket) -> None: + 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, diff --git a/tests/lua/endpoints/test_buy.py b/tests/lua/endpoints/test_buy.py index 615abd8..1b3dc61 100644 --- a/tests/lua/endpoints/test_buy.py +++ b/tests/lua/endpoints/test_buy.py @@ -1,7 +1,6 @@ """Tests for src/lua/endpoints/buy.lua""" -import socket - +import httpx import pytest from tests.lua.conftest import ( @@ -16,7 +15,7 @@ class TestBuyEndpoint: """Test basic buy endpoint functionality.""" @pytest.mark.flaky(reruns=2) - def test_buy_no_args(self, client: socket.socket) -> None: + 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" @@ -28,7 +27,7 @@ def test_buy_no_args(self, client: socket.socket) -> None: ) @pytest.mark.flaky(reruns=2) - def test_buy_multi_args(self, client: socket.socket) -> None: + 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" @@ -39,7 +38,7 @@ def test_buy_multi_args(self, client: socket.socket) -> None: "Invalid arguments. Cannot provide more than one of: card, voucher, or pack", ) - def test_buy_no_card_in_shop_area(self, client: socket.socket) -> None: + 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" @@ -50,7 +49,7 @@ def test_buy_no_card_in_shop_area(self, client: socket.socket) -> None: "No jokers/consumables/cards in the shop. Reroll to restock the shop", ) - def test_buy_invalid_index(self, client: socket.socket) -> None: + 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" @@ -61,7 +60,7 @@ def test_buy_invalid_index(self, client: socket.socket) -> None: "Card index out of range. Index: 999, Available cards: 2", ) - def test_buy_insufficient_funds(self, client: socket.socket) -> None: + 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" @@ -72,7 +71,7 @@ def test_buy_insufficient_funds(self, client: socket.socket) -> None: "Card is not affordable. Cost: 5, Current money: 0", ) - def test_buy_joker_slots_full(self, client: socket.socket) -> None: + 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" @@ -86,7 +85,7 @@ def test_buy_joker_slots_full(self, client: socket.socket) -> None: "Cannot purchase joker card, joker slots are full. Current: 5, Limit: 5", ) - def test_buy_consumable_slots_full(self, client: socket.socket) -> None: + 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, @@ -102,7 +101,7 @@ def test_buy_consumable_slots_full(self, client: socket.socket) -> None: "Cannot purchase consumable card, consumable slots are full. Current: 2, Limit: 2", ) - def test_buy_vouchers_slot_empty(self, client: socket.socket) -> None: + 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" @@ -116,7 +115,7 @@ def test_buy_vouchers_slot_empty(self, client: socket.socket) -> None: @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: socket.socket) -> None: + 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" @@ -127,7 +126,7 @@ def test_buy_packs_slot_empty(self, client: socket.socket) -> None: "No vouchers to redeem. Defeat boss blind to restock", ) - def test_buy_joker_success(self, client: socket.socket) -> None: + 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" @@ -136,7 +135,7 @@ def test_buy_joker_success(self, client: socket.socket) -> None: gamestate = assert_gamestate_response(response) assert gamestate["jokers"]["cards"][0]["set"] == "JOKER" - def test_buy_consumable_success(self, client: socket.socket) -> None: + 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" @@ -145,7 +144,7 @@ def test_buy_consumable_success(self, client: socket.socket) -> None: gamestate = assert_gamestate_response(response) assert gamestate["consumables"]["cards"][0]["set"] == "PLANET" - def test_buy_voucher_success(self, client: socket.socket) -> None: + 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" @@ -157,7 +156,7 @@ def test_buy_voucher_success(self, client: socket.socket) -> None: assert gamestate["used_vouchers"] is not None assert len(gamestate["used_vouchers"]) > 0 - def test_buy_packs_success(self, client: socket.socket) -> None: + def test_buy_packs_success(self, client: httpx.Client) -> None: """Test buying a pack from shop.""" gamestate = load_fixture( client, @@ -176,7 +175,7 @@ def test_buy_packs_success(self, client: socket.socket) -> None: class TestBuyEndpointValidation: """Test buy endpoint parameter validation.""" - def test_invalid_card_type_string(self, client: socket.socket) -> None: + 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" @@ -187,7 +186,7 @@ def test_invalid_card_type_string(self, client: socket.socket) -> None: "Field 'card' must be an integer", ) - def test_invalid_voucher_type_string(self, client: socket.socket) -> None: + 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" @@ -198,7 +197,7 @@ def test_invalid_voucher_type_string(self, client: socket.socket) -> None: "Field 'voucher' must be an integer", ) - def test_invalid_pack_type_string(self, client: socket.socket) -> None: + 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" @@ -213,7 +212,7 @@ def test_invalid_pack_type_string(self, client: socket.socket) -> None: class TestBuyEndpointStateRequirements: """Test buy endpoint state requirements.""" - def test_buy_from_BLIND_SELECT(self, client: socket.socket) -> None: + 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" diff --git a/tests/lua/endpoints/test_cash_out.py b/tests/lua/endpoints/test_cash_out.py index 515079a..b9b8bae 100644 --- a/tests/lua/endpoints/test_cash_out.py +++ b/tests/lua/endpoints/test_cash_out.py @@ -1,6 +1,6 @@ """Tests for src/lua/endpoints/cash_out.lua""" -import socket +import httpx from tests.lua.conftest import ( api, @@ -13,7 +13,7 @@ class TestCashOutEndpoint: """Test basic cash_out endpoint functionality.""" - def test_cash_out_from_ROUND_EVAL(self, client: socket.socket) -> None: + 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" @@ -24,7 +24,7 @@ def test_cash_out_from_ROUND_EVAL(self, client: socket.socket) -> None: class TestCashOutEndpointStateRequirements: """Test cash_out endpoint state requirements.""" - def test_cash_out_from_BLIND_SELECT(self, client: socket.socket): + 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" diff --git a/tests/lua/endpoints/test_discard.py b/tests/lua/endpoints/test_discard.py index 0311f9a..422deae 100644 --- a/tests/lua/endpoints/test_discard.py +++ b/tests/lua/endpoints/test_discard.py @@ -1,6 +1,6 @@ """Tests for src/lua/endpoints/discard.lua""" -import socket +import httpx from tests.lua.conftest import ( api, @@ -13,7 +13,7 @@ class TestDiscardEndpoint: """Test basic discard endpoint functionality.""" - def test_discard_zero_cards(self, client: socket.socket) -> None: + 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" @@ -23,7 +23,7 @@ def test_discard_zero_cards(self, client: socket.socket) -> None: "Must provide at least one card to discard", ) - def test_discard_too_many_cards(self, client: socket.socket) -> None: + 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" @@ -33,7 +33,7 @@ def test_discard_too_many_cards(self, client: socket.socket) -> None: "You can only discard 5 cards", ) - def test_discard_out_of_range_cards(self, client: socket.socket) -> None: + 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" @@ -43,7 +43,7 @@ def test_discard_out_of_range_cards(self, client: socket.socket) -> None: "Invalid card index: 999", ) - def test_discard_no_discards_left(self, client: socket.socket) -> None: + 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" @@ -56,7 +56,7 @@ def test_discard_no_discards_left(self, client: socket.socket) -> None: "No discards left", ) - def test_discard_valid_single_card(self, client: socket.socket) -> None: + 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" @@ -64,7 +64,7 @@ def test_discard_valid_single_card(self, client: socket.socket) -> None: 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: socket.socket) -> None: + 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" @@ -76,7 +76,7 @@ def test_discard_valid_multiple_cards(self, client: socket.socket) -> None: class TestDiscardEndpointValidation: """Test discard endpoint parameter validation.""" - def test_missing_cards_parameter(self, client: socket.socket): + 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" @@ -86,7 +86,7 @@ def test_missing_cards_parameter(self, client: socket.socket): "Missing required field 'cards'", ) - def test_invalid_cards_type(self, client: socket.socket): + 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" @@ -100,7 +100,7 @@ def test_invalid_cards_type(self, client: socket.socket): class TestDiscardEndpointStateRequirements: """Test discard endpoint state requirements.""" - def test_discard_from_BLIND_SELECT(self, client: socket.socket): + 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" diff --git a/tests/lua/endpoints/test_gamestate.py b/tests/lua/endpoints/test_gamestate.py index 3febb85..6e868b2 100644 --- a/tests/lua/endpoints/test_gamestate.py +++ b/tests/lua/endpoints/test_gamestate.py @@ -1,6 +1,6 @@ """Tests for src/lua/endpoints/gamestate.lua""" -import socket +import httpx from tests.lua.conftest import api, assert_gamestate_response, load_fixture @@ -8,13 +8,13 @@ class TestGamestateEndpoint: """Test basic gamestate endpoint and gamestate response structure.""" - def test_gamestate_from_MENU(self, client: socket.socket) -> None: + 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: socket.socket) -> None: + def test_gamestate_from_BLIND_SELECT(self, client: httpx.Client) -> None: """Test that gamestate from BLIND_SELECT state is valid.""" fixture_name = "state-BLIND_SELECT--round_num-0--deck-RED--stake-WHITE" gamestate = load_fixture(client, "gamestate", fixture_name) diff --git a/tests/lua/endpoints/test_health.py b/tests/lua/endpoints/test_health.py index 42449d5..6724fc9 100644 --- a/tests/lua/endpoints/test_health.py +++ b/tests/lua/endpoints/test_health.py @@ -5,7 +5,7 @@ # - Basic health check functionality # - Response structure and fields -import socket +import httpx from tests.lua.conftest import ( api, @@ -18,13 +18,13 @@ class TestHealthEndpoint: """Test basic health endpoint functionality.""" - def test_health_from_MENU(self, client: socket.socket) -> None: + 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: socket.socket) -> None: + 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) diff --git a/tests/lua/endpoints/test_load.py b/tests/lua/endpoints/test_load.py index 0bc4479..ac94b60 100644 --- a/tests/lua/endpoints/test_load.py +++ b/tests/lua/endpoints/test_load.py @@ -1,8 +1,9 @@ """Tests for src/lua/endpoints/load.lua""" -import socket from pathlib import Path +import httpx + from tests.lua.conftest import ( api, assert_error_response, @@ -15,7 +16,7 @@ class TestLoadEndpoint: """Test basic load endpoint functionality.""" - def test_load_from_fixture(self, client: socket.socket) -> None: + 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" @@ -24,7 +25,7 @@ def test_load_from_fixture(self, client: socket.socket) -> None: assert_path_response(response) assert response["result"]["path"] == str(fixture_path) - def test_load_save_roundtrip(self, client: socket.socket, tmp_path: Path) -> None: + 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") @@ -47,7 +48,7 @@ def test_load_save_roundtrip(self, client: socket.socket, tmp_path: Path) -> Non class TestLoadValidation: """Test load endpoint parameter validation.""" - def test_missing_path_parameter(self, client: socket.socket) -> None: + 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", {}), @@ -55,7 +56,7 @@ def test_missing_path_parameter(self, client: socket.socket) -> None: "Missing required field 'path'", ) - def test_invalid_path_type(self, client: socket.socket) -> None: + 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}), diff --git a/tests/lua/endpoints/test_menu.py b/tests/lua/endpoints/test_menu.py index 9c60857..f40837f 100644 --- a/tests/lua/endpoints/test_menu.py +++ b/tests/lua/endpoints/test_menu.py @@ -1,6 +1,6 @@ """Tests for src/lua/endpoints/menu.lua""" -import socket +import httpx from tests.lua.conftest import api, assert_gamestate_response, load_fixture @@ -8,13 +8,13 @@ class TestMenuEndpoint: """Test basic menu endpoint and menu response structure.""" - def test_menu_from_MENU(self, client: socket.socket) -> None: + 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: socket.socket) -> None: + 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" diff --git a/tests/lua/endpoints/test_next_round.py b/tests/lua/endpoints/test_next_round.py index 56a3664..4ddc8e2 100644 --- a/tests/lua/endpoints/test_next_round.py +++ b/tests/lua/endpoints/test_next_round.py @@ -1,6 +1,6 @@ """Tests for src/lua/endpoints/next_round.lua""" -import socket +import httpx from tests.lua.conftest import ( api, @@ -13,7 +13,7 @@ class TestNextRoundEndpoint: """Test basic next_round endpoint functionality.""" - def test_next_round_from_shop(self, client: socket.socket) -> None: + 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" @@ -24,7 +24,7 @@ def test_next_round_from_shop(self, client: socket.socket) -> None: class TestNextRoundEndpointStateRequirements: """Test next_round endpoint state requirements.""" - def test_next_round_from_MENU(self, client: socket.socket): + 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" diff --git a/tests/lua/endpoints/test_play.py b/tests/lua/endpoints/test_play.py index f0affd5..8dee552 100644 --- a/tests/lua/endpoints/test_play.py +++ b/tests/lua/endpoints/test_play.py @@ -1,6 +1,6 @@ """Tests for src/lua/endpoints/play.lua""" -import socket +import httpx from tests.lua.conftest import ( api, @@ -13,7 +13,7 @@ class TestPlayEndpoint: """Test basic play endpoint functionality.""" - def test_play_zero_cards(self, client: socket.socket) -> None: + 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" @@ -23,7 +23,7 @@ def test_play_zero_cards(self, client: socket.socket) -> None: "Must provide at least one card to play", ) - def test_play_six_cards(self, client: socket.socket) -> None: + 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" @@ -33,7 +33,7 @@ def test_play_six_cards(self, client: socket.socket) -> None: "You can only play 5 cards", ) - def test_play_out_of_range_cards(self, client: socket.socket) -> None: + 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" @@ -43,7 +43,7 @@ def test_play_out_of_range_cards(self, client: socket.socket) -> None: "Invalid card index: 999", ) - def test_play_valid_cards_and_round_active(self, client: socket.socket) -> None: + 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" @@ -52,7 +52,7 @@ def test_play_valid_cards_and_round_active(self, client: socket.socket) -> None: assert gamestate["hands"]["Flush"]["played_this_round"] == 1 assert gamestate["round"]["chips"] == 260 - def test_play_valid_cards_and_round_won(self, client: socket.socket) -> None: + 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" @@ -62,7 +62,7 @@ def test_play_valid_cards_and_round_won(self, client: socket.socket) -> None: 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: socket.socket) -> None: + def test_play_valid_cards_and_game_won(self, client: httpx.Client) -> None: """Test play endpoint from BLIND_SELECT state.""" gamestate = load_fixture( client, @@ -76,7 +76,7 @@ def test_play_valid_cards_and_game_won(self, client: socket.socket) -> None: 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: socket.socket) -> None: + 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" @@ -90,7 +90,7 @@ def test_play_valid_cards_and_game_over(self, client: socket.socket) -> None: class TestPlayEndpointValidation: """Test play endpoint parameter validation.""" - def test_missing_cards_parameter(self, client: socket.socket): + 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" @@ -100,7 +100,7 @@ def test_missing_cards_parameter(self, client: socket.socket): "Missing required field 'cards'", ) - def test_invalid_cards_type(self, client: socket.socket): + 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" @@ -114,7 +114,7 @@ def test_invalid_cards_type(self, client: socket.socket): class TestPlayEndpointStateRequirements: """Test play endpoint state requirements.""" - def test_play_from_BLIND_SELECT(self, client: socket.socket): + 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" diff --git a/tests/lua/endpoints/test_rearrange.py b/tests/lua/endpoints/test_rearrange.py index 1e21028..744f237 100644 --- a/tests/lua/endpoints/test_rearrange.py +++ b/tests/lua/endpoints/test_rearrange.py @@ -1,6 +1,6 @@ """Tests for src/lua/endpoints/rearrange.lua""" -import socket +import httpx from tests.lua.conftest import ( api, @@ -13,7 +13,7 @@ class TestRearrangeEndpoint: """Test basic rearrange endpoint functionality.""" - def test_rearrange_hand(self, client: socket.socket) -> None: + 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" @@ -29,7 +29,7 @@ def test_rearrange_hand(self, client: socket.socket) -> None: ids = [card["id"] for card in after["hand"]["cards"]] assert ids == [prev_ids[i] for i in permutation] - def test_rearrange_jokers(self, client: socket.socket) -> None: + 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" @@ -47,7 +47,7 @@ def test_rearrange_jokers(self, client: socket.socket) -> None: ids = [card["id"] for card in after["jokers"]["cards"]] assert ids == [prev_ids[i] for i in permutation] - def test_rearrange_consumables(self, client: socket.socket) -> None: + 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" @@ -69,7 +69,7 @@ def test_rearrange_consumables(self, client: socket.socket) -> None: class TestRearrangeEndpointValidation: """Test rearrange endpoint parameter validation.""" - def test_no_parameters_provided(self, client: socket.socket) -> None: + 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" @@ -82,7 +82,7 @@ def test_no_parameters_provided(self, client: socket.socket) -> None: "Must provide exactly one of: hand, jokers, or consumables", ) - def test_multiple_parameters_provided(self, client: socket.socket) -> None: + 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" @@ -97,7 +97,7 @@ def test_multiple_parameters_provided(self, client: socket.socket) -> None: "Can only rearrange one type at a time", ) - def test_wrong_array_length_hand(self, client: socket.socket) -> None: + 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" @@ -115,7 +115,7 @@ def test_wrong_array_length_hand(self, client: socket.socket) -> None: "Must provide exactly 8 indices for hand", ) - def test_wrong_array_length_jokers(self, client: socket.socket) -> None: + 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" @@ -133,7 +133,7 @@ def test_wrong_array_length_jokers(self, client: socket.socket) -> None: "Must provide exactly 4 indices for jokers", ) - def test_wrong_array_length_consumables(self, client: socket.socket) -> None: + 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" @@ -151,7 +151,7 @@ def test_wrong_array_length_consumables(self, client: socket.socket) -> None: "Must provide exactly 2 indices for consumables", ) - def test_invalid_card_index(self, client: socket.socket) -> None: + 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" @@ -169,7 +169,7 @@ def test_invalid_card_index(self, client: socket.socket) -> None: "Index out of range for hand: -1", ) - def test_duplicate_indices(self, client: socket.socket) -> None: + 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" @@ -191,7 +191,7 @@ def test_duplicate_indices(self, client: socket.socket) -> None: class TestRearrangeEndpointStateRequirements: """Test rearrange endpoint state requirements.""" - def test_rearrange_hand_from_wrong_state(self, client: socket.socket) -> None: + 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" @@ -201,7 +201,7 @@ def test_rearrange_hand_from_wrong_state(self, client: socket.socket) -> None: "Method 'rearrange' requires one of these states: SELECTING_HAND, SHOP", ) - def test_rearrange_jokers_from_wrong_state(self, client: socket.socket) -> None: + 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" @@ -211,9 +211,7 @@ def test_rearrange_jokers_from_wrong_state(self, client: socket.socket) -> None: "Method 'rearrange' requires one of these states: SELECTING_HAND, SHOP", ) - def test_rearrange_consumables_from_wrong_state( - self, client: socket.socket - ) -> None: + 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" @@ -223,7 +221,7 @@ def test_rearrange_consumables_from_wrong_state( "Method 'rearrange' requires one of these states: SELECTING_HAND, SHOP", ) - def test_rearrange_hand_from_shop(self, client: socket.socket) -> None: + 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" diff --git a/tests/lua/endpoints/test_reroll.py b/tests/lua/endpoints/test_reroll.py index ce10e27..3f29191 100644 --- a/tests/lua/endpoints/test_reroll.py +++ b/tests/lua/endpoints/test_reroll.py @@ -1,6 +1,6 @@ """Tests for src/lua/endpoints/reroll.lua""" -import socket +import httpx from tests.lua.conftest import ( api, @@ -13,7 +13,7 @@ class TestRerollEndpoint: """Test basic reroll endpoint functionality.""" - def test_reroll_from_shop(self, client: socket.socket) -> None: + 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" @@ -21,7 +21,7 @@ def test_reroll_from_shop(self, client: socket.socket) -> None: after = assert_gamestate_response(response, state="SHOP") assert before["shop"] != after["shop"] - def test_reroll_insufficient_funds(self, client: socket.socket) -> None: + 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" @@ -36,7 +36,7 @@ def test_reroll_insufficient_funds(self, client: socket.socket) -> None: class TestRerollEndpointStateRequirements: """Test reroll endpoint state requirements.""" - def test_reroll_from_BLIND_SELECT(self, client: socket.socket): + 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" diff --git a/tests/lua/endpoints/test_save.py b/tests/lua/endpoints/test_save.py index 0e94d32..9279917 100644 --- a/tests/lua/endpoints/test_save.py +++ b/tests/lua/endpoints/test_save.py @@ -1,8 +1,9 @@ """Tests for src/lua/endpoints/save.lua""" -import socket from pathlib import Path +import httpx + from tests.lua.conftest import ( api, assert_error_response, @@ -14,9 +15,7 @@ class TestSaveEndpoint: """Test basic save endpoint functionality.""" - def test_save_from_BLIND_SELECT( - self, client: socket.socket, tmp_path: Path - ) -> None: + 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" @@ -28,7 +27,7 @@ def test_save_from_BLIND_SELECT( assert temp_file.stat().st_size > 0 def test_save_creates_valid_file( - self, client: socket.socket, tmp_path: Path + 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") @@ -43,7 +42,7 @@ def test_save_creates_valid_file( class TestSaveValidation: """Test save endpoint parameter validation.""" - def test_missing_path_parameter(self, client: socket.socket) -> None: + 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( @@ -52,7 +51,7 @@ def test_missing_path_parameter(self, client: socket.socket) -> None: "Missing required field 'path'", ) - def test_invalid_path_type(self, client: socket.socket) -> None: + 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( @@ -65,7 +64,7 @@ def test_invalid_path_type(self, client: socket.socket) -> None: class TestSaveStateRequirements: """Test save endpoint state requirements.""" - def test_save_from_MENU(self, client: socket.socket, tmp_path: Path) -> None: + 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" diff --git a/tests/lua/endpoints/test_screenshot.py b/tests/lua/endpoints/test_screenshot.py index 57c4f22..967bff7 100644 --- a/tests/lua/endpoints/test_screenshot.py +++ b/tests/lua/endpoints/test_screenshot.py @@ -1,8 +1,9 @@ """Tests for src/lua/endpoints/screenshot.lua""" -import socket from pathlib import Path +import httpx + from tests.lua.conftest import ( api, assert_error_response, @@ -15,7 +16,7 @@ class TestScreenshotEndpoint: """Test basic screenshot endpoint functionality.""" - def test_screenshot_from_MENU(self, client: socket.socket, tmp_path: Path) -> None: + 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") @@ -28,7 +29,7 @@ def test_screenshot_from_MENU(self, client: socket.socket, tmp_path: Path) -> No assert temp_file.read_bytes()[:8] == b"\x89PNG\r\n\x1a\n" def test_screenshot_from_BLIND_SELECT( - self, client: socket.socket, tmp_path: Path + self, client: httpx.Client, tmp_path: Path ) -> None: """Test that screenshot succeeds from BLIND_SELECT state.""" gamestate = load_fixture(client, "screenshot", "state-BLIND_SELECT") @@ -45,7 +46,7 @@ def test_screenshot_from_BLIND_SELECT( class TestScreenshotValidation: """Test screenshot endpoint parameter validation.""" - def test_missing_path_parameter(self, client: socket.socket) -> None: + 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( @@ -54,7 +55,7 @@ def test_missing_path_parameter(self, client: socket.socket) -> None: "Missing required field 'path'", ) - def test_invalid_path_type(self, client: socket.socket) -> None: + 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( diff --git a/tests/lua/endpoints/test_select.py b/tests/lua/endpoints/test_select.py index 9131ae2..64d2cce 100644 --- a/tests/lua/endpoints/test_select.py +++ b/tests/lua/endpoints/test_select.py @@ -1,6 +1,6 @@ """Tests for src/lua/endpoints/select.lua""" -import socket +import httpx from tests.lua.conftest import ( api, @@ -13,7 +13,7 @@ class TestSelectEndpoint: """Test basic select endpoint functionality.""" - def test_select_small_blind(self, client: socket.socket) -> None: + 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" @@ -23,7 +23,7 @@ def test_select_small_blind(self, client: socket.socket) -> None: response = api(client, "select", {}) assert_gamestate_response(response, state="SELECTING_HAND") - def test_select_big_blind(self, client: socket.socket) -> None: + 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" @@ -33,7 +33,7 @@ def test_select_big_blind(self, client: socket.socket) -> None: response = api(client, "select", {}) assert_gamestate_response(response, state="SELECTING_HAND") - def test_select_boss_blind(self, client: socket.socket) -> None: + 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" @@ -47,7 +47,7 @@ def test_select_boss_blind(self, client: socket.socket) -> None: class TestSelectEndpointStateRequirements: """Test select endpoint state requirements.""" - def test_select_from_MENU(self, client: socket.socket): + 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") diff --git a/tests/lua/endpoints/test_sell.py b/tests/lua/endpoints/test_sell.py index 86ec555..9090ae9 100644 --- a/tests/lua/endpoints/test_sell.py +++ b/tests/lua/endpoints/test_sell.py @@ -1,6 +1,6 @@ """Tests for src/lua/endpoints/sell.lua""" -import socket +import httpx from tests.lua.conftest import ( api, @@ -13,7 +13,7 @@ class TestSellEndpoint: """Test basic sell endpoint functionality.""" - def test_sell_no_args(self, client: socket.socket) -> None: + 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" @@ -25,7 +25,7 @@ def test_sell_no_args(self, client: socket.socket) -> None: "Must provide exactly one of: joker or consumable", ) - def test_sell_multi_args(self, client: socket.socket) -> None: + 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" @@ -37,7 +37,7 @@ def test_sell_multi_args(self, client: socket.socket) -> None: "Can only sell one item at a time", ) - def test_sell_no_jokers(self, client: socket.socket) -> None: + 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" @@ -50,7 +50,7 @@ def test_sell_no_jokers(self, client: socket.socket) -> None: "No jokers available to sell", ) - def test_sell_no_consumables(self, client: socket.socket) -> None: + 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" @@ -63,7 +63,7 @@ def test_sell_no_consumables(self, client: socket.socket) -> None: "No consumables available to sell", ) - def test_sell_joker_invalid_index(self, client: socket.socket) -> None: + 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" @@ -76,7 +76,7 @@ def test_sell_joker_invalid_index(self, client: socket.socket) -> None: "Index out of range for joker: 1", ) - def test_sell_consumable_invalid_index(self, client: socket.socket) -> None: + 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" @@ -89,7 +89,7 @@ def test_sell_consumable_invalid_index(self, client: socket.socket) -> None: "Index out of range for consumable: 1", ) - def test_sell_joker_in_SELECTING_HAND(self, client: socket.socket) -> None: + def test_sell_joker_in_SELECTING_HAND(self, client: httpx.Client) -> None: """Test selling a joker in SELECTING_HAND state.""" before = load_fixture( client, @@ -103,7 +103,7 @@ def test_sell_joker_in_SELECTING_HAND(self, client: socket.socket) -> None: assert after["jokers"]["count"] == 0 assert before["money"] < after["money"] - def test_sell_consumable_in_SELECTING_HAND(self, client: socket.socket) -> None: + 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" @@ -115,7 +115,7 @@ def test_sell_consumable_in_SELECTING_HAND(self, client: socket.socket) -> None: assert after["consumables"]["count"] == 0 assert before["money"] < after["money"] - def test_sell_joker_in_SHOP(self, client: socket.socket) -> None: + 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" @@ -127,7 +127,7 @@ def test_sell_joker_in_SHOP(self, client: socket.socket) -> None: assert after["jokers"]["count"] == 0 assert before["money"] < after["money"] - def test_sell_consumable_in_SHOP(self, client: socket.socket) -> None: + 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" @@ -143,7 +143,7 @@ def test_sell_consumable_in_SHOP(self, client: socket.socket) -> None: class TestSellEndpointValidation: """Test sell endpoint parameter validation.""" - def test_invalid_joker_type_string(self, client: socket.socket) -> None: + 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" @@ -156,7 +156,7 @@ def test_invalid_joker_type_string(self, client: socket.socket) -> None: "Field 'joker' must be an integer", ) - def test_invalid_consumable_type_string(self, client: socket.socket) -> None: + 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" @@ -173,7 +173,7 @@ def test_invalid_consumable_type_string(self, client: socket.socket) -> None: class TestSellEndpointStateRequirements: """Test sell endpoint state requirements.""" - def test_sell_from_BLIND_SELECT(self, client: socket.socket) -> None: + 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" @@ -183,7 +183,7 @@ def test_sell_from_BLIND_SELECT(self, client: socket.socket) -> None: "Method 'sell' requires one of these states: SELECTING_HAND, SHOP", ) - def test_sell_from_ROUND_EVAL(self, client: socket.socket) -> None: + 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" diff --git a/tests/lua/endpoints/test_set.py b/tests/lua/endpoints/test_set.py index 91e34d7..65fb900 100644 --- a/tests/lua/endpoints/test_set.py +++ b/tests/lua/endpoints/test_set.py @@ -1,6 +1,6 @@ """Tests for src/lua/endpoints/set.lua""" -import socket +import httpx from tests.lua.conftest import ( api, @@ -13,7 +13,7 @@ class TestSetEndpoint: """Test basic set endpoint functionality.""" - def test_set_game_not_in_run(self, client: socket.socket) -> None: + 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", {}) @@ -23,7 +23,7 @@ def test_set_game_not_in_run(self, client: socket.socket) -> None: "Can only set during an active run", ) - def test_set_no_fields(self, client: socket.socket) -> None: + 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" @@ -34,7 +34,7 @@ def test_set_no_fields(self, client: socket.socket) -> None: "Must provide at least one field to set", ) - def test_set_negative_money(self, client: socket.socket) -> None: + 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" @@ -45,14 +45,14 @@ def test_set_negative_money(self, client: socket.socket) -> None: "Money must be a positive integer", ) - def test_set_money(self, client: socket.socket) -> None: + 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: socket.socket) -> None: + 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" @@ -63,7 +63,7 @@ def test_set_negative_chips(self, client: socket.socket) -> None: "Chips must be a positive integer", ) - def test_set_chips(self, client: socket.socket) -> None: + 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" @@ -71,7 +71,7 @@ def test_set_chips(self, client: socket.socket) -> None: gamestate = assert_gamestate_response(response) assert gamestate["round"]["chips"] == 100 - def test_set_negative_ante(self, client: socket.socket) -> None: + 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" @@ -82,14 +82,14 @@ def test_set_negative_ante(self, client: socket.socket) -> None: "Ante must be a positive integer", ) - def test_set_ante(self, client: socket.socket) -> None: + 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: socket.socket) -> None: + 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" @@ -100,14 +100,14 @@ def test_set_negative_round(self, client: socket.socket) -> None: "Round must be a positive integer", ) - def test_set_round(self, client: socket.socket) -> None: + 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: socket.socket) -> None: + 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" @@ -118,7 +118,7 @@ def test_set_negative_hands(self, client: socket.socket) -> None: "Hands must be a positive integer", ) - def test_set_hands(self, client: socket.socket) -> None: + 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" @@ -126,7 +126,7 @@ def test_set_hands(self, client: socket.socket) -> None: gamestate = assert_gamestate_response(response) assert gamestate["round"]["hands_left"] == 10 - def test_set_negative_discards(self, client: socket.socket) -> None: + 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" @@ -137,7 +137,7 @@ def test_set_negative_discards(self, client: socket.socket) -> None: "Discards must be a positive integer", ) - def test_set_discards(self, client: socket.socket) -> None: + 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" @@ -145,7 +145,7 @@ def test_set_discards(self, client: socket.socket) -> None: gamestate = assert_gamestate_response(response) assert gamestate["round"]["discards_left"] == 10 - def test_set_shop_from_selecting_hand(self, client: socket.socket) -> None: + 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" @@ -156,7 +156,7 @@ def test_set_shop_from_selecting_hand(self, client: socket.socket) -> None: "Can re-stock shop only in SHOP state", ) - def test_set_shop_from_SHOP(self, client: socket.socket) -> None: + 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" @@ -168,7 +168,7 @@ def test_set_shop_from_SHOP(self, client: socket.socket) -> None: 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: socket.socket) -> None: + 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" @@ -182,7 +182,7 @@ def test_set_shop_set_round_set_money(self, client: socket.socket) -> None: class TestSetEndpointValidation: """Test set endpoint parameter validation.""" - def test_invalid_money_type(self, client: socket.socket): + 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" @@ -193,7 +193,7 @@ def test_invalid_money_type(self, client: socket.socket): "Field 'money' must be an integer", ) - def test_invalid_chips_type(self, client: socket.socket): + 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" @@ -204,7 +204,7 @@ def test_invalid_chips_type(self, client: socket.socket): "Field 'chips' must be an integer", ) - def test_invalid_ante_type(self, client: socket.socket): + 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" @@ -215,7 +215,7 @@ def test_invalid_ante_type(self, client: socket.socket): "Field 'ante' must be an integer", ) - def test_invalid_round_type(self, client: socket.socket): + 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" @@ -226,7 +226,7 @@ def test_invalid_round_type(self, client: socket.socket): "Field 'round' must be an integer", ) - def test_invalid_hands_type(self, client: socket.socket): + 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" @@ -237,7 +237,7 @@ def test_invalid_hands_type(self, client: socket.socket): "Field 'hands' must be an integer", ) - def test_invalid_discards_type(self, client: socket.socket): + 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" @@ -248,7 +248,7 @@ def test_invalid_discards_type(self, client: socket.socket): "Field 'discards' must be an integer", ) - def test_invalid_shop_type(self, client: socket.socket): + 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" diff --git a/tests/lua/endpoints/test_skip.py b/tests/lua/endpoints/test_skip.py index 46770b7..5a89edc 100644 --- a/tests/lua/endpoints/test_skip.py +++ b/tests/lua/endpoints/test_skip.py @@ -1,6 +1,6 @@ """Tests for src/lua/endpoints/skip.lua""" -import socket +import httpx from tests.lua.conftest import ( api, @@ -13,7 +13,7 @@ class TestSkipEndpoint: """Test basic skip endpoint functionality.""" - def test_skip_small_blind(self, client: socket.socket) -> None: + 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" @@ -25,7 +25,7 @@ def test_skip_small_blind(self, client: socket.socket) -> None: assert gamestate["blinds"]["small"]["status"] == "SKIPPED" assert gamestate["blinds"]["big"]["status"] == "SELECT" - def test_skip_big_blind(self, client: socket.socket) -> None: + 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" @@ -37,7 +37,7 @@ def test_skip_big_blind(self, client: socket.socket) -> None: assert gamestate["blinds"]["big"]["status"] == "SKIPPED" assert gamestate["blinds"]["boss"]["status"] == "SELECT" - def test_skip_big_boss(self, client: socket.socket) -> None: + 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" @@ -54,7 +54,7 @@ def test_skip_big_boss(self, client: socket.socket) -> None: class TestSkipEndpointStateRequirements: """Test skip endpoint state requirements.""" - def test_skip_from_MENU(self, client: socket.socket): + 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") diff --git a/tests/lua/endpoints/test_start.py b/tests/lua/endpoints/test_start.py index 36d08f1..7502475 100644 --- a/tests/lua/endpoints/test_start.py +++ b/tests/lua/endpoints/test_start.py @@ -1,8 +1,8 @@ """Tests for the start endpoint.""" -import socket from typing import Any +import httpx import pytest from tests.lua.conftest import ( @@ -68,7 +68,7 @@ class TestStartEndpoint: ) def test_start_from_MENU( self, - client: socket.socket, + client: httpx.Client, arguments: dict[str, Any], expected: dict[str, Any], ): @@ -82,7 +82,7 @@ def test_start_from_MENU( class TestStartEndpointValidation: """Test start endpoint parameter validation.""" - def test_missing_deck_parameter(self, client: socket.socket): + 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") @@ -93,7 +93,7 @@ def test_missing_deck_parameter(self, client: socket.socket): "Missing required field 'deck'", ) - def test_missing_stake_parameter(self, client: socket.socket): + 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") @@ -104,7 +104,7 @@ def test_missing_stake_parameter(self, client: socket.socket): "Missing required field 'stake'", ) - def test_invalid_deck_value(self, client: socket.socket): + 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") @@ -115,7 +115,7 @@ def test_invalid_deck_value(self, client: socket.socket): "Invalid deck enum. Must be one of:", ) - def test_invalid_stake_value(self, client: socket.socket): + 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") @@ -126,7 +126,7 @@ def test_invalid_stake_value(self, client: socket.socket): "Invalid stake enum. Must be one of:", ) - def test_invalid_deck_type(self, client: socket.socket): + 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") @@ -137,7 +137,7 @@ def test_invalid_deck_type(self, client: socket.socket): "Field 'deck' must be of type string", ) - def test_invalid_stake_type(self, client: socket.socket): + 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") @@ -152,7 +152,7 @@ def test_invalid_stake_type(self, client: socket.socket): class TestStartEndpointStateRequirements: """Test start endpoint state requirements.""" - def test_start_from_BLIND_SELECT(self, client: socket.socket): + 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" diff --git a/tests/lua/endpoints/test_use.py b/tests/lua/endpoints/test_use.py index 65ed375..997edb9 100644 --- a/tests/lua/endpoints/test_use.py +++ b/tests/lua/endpoints/test_use.py @@ -1,6 +1,6 @@ """Tests for src/lua/endpoints/use.lua""" -import socket +import httpx from tests.lua.conftest import ( api, @@ -13,7 +13,7 @@ class TestUseEndpoint: """Test basic use endpoint functionality.""" - def test_use_hermit_no_cards(self, client: socket.socket) -> None: + 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, @@ -26,7 +26,7 @@ def test_use_hermit_no_cards(self, client: socket.socket) -> None: response = api(client, "use", {"consumable": 0}) assert_gamestate_response(response, money=24) - def test_use_hermit_in_selecting_hand(self, client: socket.socket) -> None: + def test_use_hermit_in_selecting_hand(self, client: httpx.Client) -> None: """Test using The Hermit in SELECTING_HAND state.""" gamestate = load_fixture( client, @@ -39,7 +39,7 @@ def test_use_hermit_in_selecting_hand(self, client: socket.socket) -> None: response = api(client, "use", {"consumable": 0}) assert_gamestate_response(response, money=24) - def test_use_temperance_no_cards(self, client: socket.socket) -> None: + def test_use_temperance_no_cards(self, client: httpx.Client) -> None: """Test using Temperance (no card selection).""" before = load_fixture( client, @@ -52,7 +52,7 @@ def test_use_temperance_no_cards(self, client: socket.socket) -> None: response = api(client, "use", {"consumable": 0}) assert_gamestate_response(response, money=before["money"]) - def test_use_planet_no_cards(self, client: socket.socket) -> None: + def test_use_planet_no_cards(self, client: httpx.Client) -> None: """Test using a Planet card (no card selection).""" gamestate = load_fixture( client, @@ -65,7 +65,7 @@ def test_use_planet_no_cards(self, client: socket.socket) -> None: after = assert_gamestate_response(response) assert after["hands"]["High Card"]["level"] == 2 - def test_use_magician_with_one_card(self, client: socket.socket) -> None: + 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, @@ -77,7 +77,7 @@ def test_use_magician_with_one_card(self, client: socket.socket) -> None: after = assert_gamestate_response(response) assert after["hand"]["cards"][0]["modifier"]["enhancement"] == "LUCKY" - def test_use_magician_with_two_cards(self, client: socket.socket) -> None: + def test_use_magician_with_two_cards(self, client: httpx.Client) -> None: """Test using The Magician with 2 cards.""" gamestate = load_fixture( client, @@ -90,7 +90,7 @@ def test_use_magician_with_two_cards(self, client: socket.socket) -> None: assert after["hand"]["cards"][5]["modifier"]["enhancement"] == "LUCKY" assert after["hand"]["cards"][7]["modifier"]["enhancement"] == "LUCKY" - def test_use_familiar_all_hand(self, client: socket.socket) -> None: + def test_use_familiar_all_hand(self, client: httpx.Client) -> None: """Test using Familiar (destroys cards, #G.hand.cards > 1).""" before = load_fixture( client, @@ -109,7 +109,7 @@ def test_use_familiar_all_hand(self, client: socket.socket) -> None: class TestUseEndpointValidation: """Test use endpoint parameter validation.""" - def test_use_no_consumable_provided(self, client: socket.socket) -> None: + def test_use_no_consumable_provided(self, client: httpx.Client) -> None: """Test that use fails when consumable parameter is missing.""" gamestate = load_fixture( client, @@ -123,7 +123,7 @@ def test_use_no_consumable_provided(self, client: socket.socket) -> None: "Missing required field 'consumable'", ) - def test_use_invalid_consumable_type(self, client: socket.socket) -> None: + 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, @@ -137,7 +137,7 @@ def test_use_invalid_consumable_type(self, client: socket.socket) -> None: "Field 'consumable' must be an integer", ) - def test_use_invalid_consumable_index_negative(self, client: socket.socket) -> None: + 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, @@ -151,7 +151,7 @@ def test_use_invalid_consumable_index_negative(self, client: socket.socket) -> N "Consumable index out of range: -1", ) - def test_use_invalid_consumable_index_too_high(self, client: socket.socket) -> None: + 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, @@ -165,7 +165,7 @@ def test_use_invalid_consumable_index_too_high(self, client: socket.socket) -> N "Consumable index out of range: 999", ) - def test_use_invalid_cards_type(self, client: socket.socket) -> None: + 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, @@ -179,7 +179,7 @@ def test_use_invalid_cards_type(self, client: socket.socket) -> None: "Field 'cards' must be an array", ) - def test_use_invalid_cards_item_type(self, client: socket.socket) -> None: + 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, @@ -193,7 +193,7 @@ def test_use_invalid_cards_item_type(self, client: socket.socket) -> None: "Field 'cards' array item at index 0 must be of type integer", ) - def test_use_invalid_card_index_negative(self, client: socket.socket) -> None: + 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, @@ -207,7 +207,7 @@ def test_use_invalid_card_index_negative(self, client: socket.socket) -> None: "Card index out of range: -1", ) - def test_use_invalid_card_index_too_high(self, client: socket.socket) -> None: + 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, @@ -221,7 +221,7 @@ def test_use_invalid_card_index_too_high(self, client: socket.socket) -> None: "Card index out of range: 999", ) - def test_use_magician_without_cards(self, client: socket.socket) -> None: + def test_use_magician_without_cards(self, client: httpx.Client) -> None: """Test that using The Magician without cards parameter fails.""" gamestate = load_fixture( client, @@ -236,7 +236,7 @@ def test_use_magician_without_cards(self, client: socket.socket) -> None: "Consumable 'The Magician' requires card selection", ) - def test_use_magician_with_empty_cards(self, client: socket.socket) -> None: + 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, @@ -251,7 +251,7 @@ def test_use_magician_with_empty_cards(self, client: socket.socket) -> None: "Consumable 'The Magician' requires card selection", ) - def test_use_magician_too_many_cards(self, client: socket.socket) -> None: + 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, @@ -266,7 +266,7 @@ def test_use_magician_too_many_cards(self, client: socket.socket) -> None: "Consumable 'The Magician' requires at most 2 cards (provided: 3)", ) - def test_use_death_too_few_cards(self, client: socket.socket) -> None: + 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, @@ -281,7 +281,7 @@ def test_use_death_too_few_cards(self, client: socket.socket) -> None: "Consumable 'Death' requires exactly 2 cards (provided: 1)", ) - def test_use_death_too_many_cards(self, client: socket.socket) -> None: + 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, @@ -300,7 +300,7 @@ def test_use_death_too_many_cards(self, client: socket.socket) -> None: class TestUseEndpointStateRequirements: """Test use endpoint state requirements.""" - def test_use_from_BLIND_SELECT(self, client: socket.socket) -> None: + def test_use_from_BLIND_SELECT(self, client: httpx.Client) -> None: """Test that use fails from BLIND_SELECT state.""" gamestate = load_fixture( client, @@ -314,7 +314,7 @@ def test_use_from_BLIND_SELECT(self, client: socket.socket) -> None: "Method 'use' requires one of these states: SELECTING_HAND, SHOP", ) - def test_use_from_ROUND_EVAL(self, client: socket.socket) -> None: + def test_use_from_ROUND_EVAL(self, client: httpx.Client) -> None: """Test that use fails from ROUND_EVAL state.""" gamestate = load_fixture( client, @@ -328,7 +328,7 @@ def test_use_from_ROUND_EVAL(self, client: socket.socket) -> None: "Method 'use' requires one of these states: SELECTING_HAND, SHOP", ) - def test_use_magician_from_SHOP(self, client: socket.socket) -> None: + 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, @@ -343,7 +343,7 @@ def test_use_magician_from_SHOP(self, client: socket.socket) -> None: "Consumable 'The Magician' requires card selection and can only be used in SELECTING_HAND state", ) - def test_use_familiar_from_SHOP(self, client: socket.socket) -> None: + 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, From fecb299b4652711198868758db8638e55248d322 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Sun, 7 Dec 2025 20:10:50 +0100 Subject: [PATCH 199/230] feat(lua.utils): add openrpc.json schema --- src/lua/utils/openrpc.json | 1454 ++++++++++++++++++++++++++++++++++++ 1 file changed, 1454 insertions(+) create mode 100644 src/lua/utils/openrpc.json diff --git a/src/lua/utils/openrpc.json b/src/lua/utils/openrpc.json new file mode 100644 index 0000000..69d5865 --- /dev/null +++ b/src/lua/utils/openrpc.json @@ -0,0 +1,1454 @@ +{ + "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": { + "type": "string" + } + }, + { + "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": { + "type": "string", + "description": "Game state enumeration", + "enum": [ + "MENU", + "BLIND_SELECT", + "SELECTING_HAND", + "HAND_PLAYED", + "DRAW_TO_HAND", + "NEW_ROUND", + "ROUND_EVAL", + "SHOP", + "PLAY_TAROT", + "TAROT_PACK", + "PLANET_PACK", + "SPECTRAL_PACK", + "STANDARD_PACK", + "BUFFOON_PACK", + "GAME_OVER" + ] + }, + "Deck": { + "type": "string", + "description": "Deck enumeration", + "enum": [ + "RED", + "BLUE", + "YELLOW", + "GREEN", + "BLACK", + "MAGIC", + "NEBULA", + "GHOST", + "ABANDONED", + "CHECKERED", + "ZODIAC", + "PAINTED", + "ANAGLYPH", + "PLASMA", + "ERRATIC" + ] + }, + "Stake": { + "type": "string", + "description": "Stake level enumeration", + "enum": [ + "WHITE", + "RED", + "GREEN", + "BLACK", + "BLUE", + "PURPLE", + "ORANGE", + "GOLD" + ] + }, + "Suit": { + "type": "string", + "description": "Card suit", + "enum": [ + "H", + "D", + "C", + "S" + ] + }, + "Rank": { + "type": "string", + "description": "Card rank", + "enum": [ + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9", + "T", + "J", + "Q", + "K", + "A" + ] + }, + "Seal": { + "type": "string", + "description": "Card seal type", + "enum": [ + "RED", + "BLUE", + "GOLD", + "PURPLE" + ] + }, + "Edition": { + "type": "string", + "description": "Card edition type", + "enum": [ + "HOLO", + "FOIL", + "POLYCHROME", + "NEGATIVE" + ] + }, + "Enhancement": { + "type": "string", + "description": "Card enhancement type", + "enum": [ + "BONUS", + "MULT", + "WILD", + "GLASS", + "STEEL", + "STONE", + "GOLD", + "LUCKY" + ] + }, + "CardSet": { + "type": "string", + "description": "Card set/type", + "enum": [ + "DEFAULT", + "ENHANCED", + "JOKER", + "TAROT", + "PLANET", + "SPECTRAL", + "VOUCHER", + "BOOSTER" + ] + }, + "BlindType": { + "type": "string", + "description": "Blind type", + "enum": [ + "SMALL", + "BIG", + "BOSS" + ] + }, + "BlindStatus": { + "type": "string", + "description": "Blind status", + "enum": [ + "UPCOMING", + "CURRENT", + "DEFEATED", + "SKIPPED", + "SELECT" + ] + } + }, + "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" + } + } + } + } +} From 325d0372f1e139a595d237a89e867252a7f22306 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Mon, 8 Dec 2025 14:19:14 +0100 Subject: [PATCH 200/230] chore: remove balatrobot python client --- src/balatrobot/__init__.py | 21 -- src/balatrobot/client.py | 501 ----------------------------------- src/balatrobot/enums.py | 478 --------------------------------- src/balatrobot/exceptions.py | 166 ------------ src/balatrobot/models.py | 402 ---------------------------- src/balatrobot/py.typed | 0 6 files changed, 1568 deletions(-) delete mode 100644 src/balatrobot/__init__.py delete mode 100644 src/balatrobot/client.py delete mode 100644 src/balatrobot/enums.py delete mode 100644 src/balatrobot/exceptions.py delete mode 100644 src/balatrobot/models.py delete mode 100644 src/balatrobot/py.typed 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 From d80b9ba3cc1d50ece6f03c80ae2e74154eb64436 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Mon, 8 Dec 2025 14:20:25 +0100 Subject: [PATCH 201/230] docs: update docs for new BalatroBot API --- docs/balatrobot-api.md | 141 ------------------ docs/contributing.md | 317 +++++++++++----------------------------- docs/developing-bots.md | 147 ------------------- docs/index.md | 20 +-- docs/installation.md | 291 +++++++----------------------------- docs/logging-systems.md | 119 --------------- docs/protocol-api.md | 230 ----------------------------- mkdocs.yml | 33 +---- 8 files changed, 150 insertions(+), 1148 deletions(-) delete mode 100644 docs/balatrobot-api.md delete mode 100644 docs/developing-bots.md delete mode 100644 docs/logging-systems.md delete mode 100644 docs/protocol-api.md 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..9abd1bd 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -1,276 +1,123 @@ -# 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 - -BalatroBot includes a comprehensive Makefile that provides a convenient interface for all development tasks. Use `make help` to see all available commands: +Instead of copying files, create a symlink for easier development: +**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 - -```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) +**Windows (PowerShell as Admin):** +```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 -``` - -**Using Checkpoints in Tests:** +# Install all dependencies +make install -```python -# In conftest.py or test files -from ..conftest import prepare_checkpoint +# Run all tests (restarts game automatically) +make test -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 -``` - -**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:** +# Run specific test file +pytest tests/lua/endpoints/test_health.py -v -```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) +# Run tests with dev marker +make test PYTEST_MARKER=dev ``` -**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 + +1. 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: - -- **`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") - -## Communication & Community +2. Add tests in `tests/lua/endpoints/test_your_endpoint.py` -### Preferred Channels +3. Update `src/lua/utils/openrpc.json` 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..9808e2c 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,11 +1,11 @@
![Image title](assets/balatrobot.svg){ width="256" } -
A framework for developing Balatro bots
+
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.
@@ -13,31 +13,31 @@ 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-api:{ .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](api.md) -- :material-api:{ .lg .middle } __Protocol API__ +- :material-robot:{ .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) diff --git a/docs/installation.md b/docs/installation.md index ae54525..5f87ee2 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -1,262 +1,79 @@ -# 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/Steamopollys/Steamodded#installation) -- **[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. Configure (Optional) -!!! tip +BalatroBot reads configuration from environment variables. Set these before launching Balatro: - You can also clone the repository somewhere else and then provide a symlink - to the `balatrobot` directory in the `Mods` directory. +| 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) | - === "Windows" +Example (bash): - ```sh - # Clone repository to a custom location - cd C:\your\custom\path - git clone https://github.com/coder/balatrobot.git +```bash +export BALATROBOT_PORT=12346 +export BALATROBOT_FAST=1 +# Then launch Balatro +``` - # Create symlink in Mods directory - cd %AppData%/Balatro/Mods - mklink /D balatrobot C:\your\custom\path\balatrobot - ``` +### 4. Verify Installation - === "MacOS" +Start Balatro, then test the connection: - ```sh - # Clone repository to a custom location - cd /your/custom/path - git clone https://github.com/coder/balatrobot.git +```bash +curl -X POST http://127.0.0.1:12346 \ + -H "Content-Type: application/json" \ + -d '{"jsonrpc": "2.0", "method": "health", "id": 1}' +``` - # Create symlink in Mods directory - cd "/Users/$USER/Library/Application Support/Balatro/Mods" - ln -s /your/custom/path/balatrobot balatrobot - ``` +Expected response: - === "Linux" - - ```sh - # Clone repository to a custom location - cd /your/custom/path - git clone https://github.com/coder/balatrobot.git - - # 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 - ``` - -??? "Update BalatroBot" - - Updating BalatroBot is as simple as pulling the latest changes from the repository. - - === "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 - - 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. - -The bot is working correctly if: - -1. Game starts automatically -2. Cards are played/discarded automatically -3. Win the first blind -4. Game progresses through blinds +```json +{"jsonrpc":"2.0","result":{"status":"ok"},"id":1} +``` ## Troubleshooting -If you encounter issues during installation or testing: - -- **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 - ---- - -*Once installation is complete, proceed to the [Developing Bots](developing-bots.md) to create your first bot!* +- **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 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..6a88ceb 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 @@ -33,36 +33,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 - 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: @@ -77,13 +59,6 @@ markdown_extensions: 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.emoji: emoji_index: !!python/name:material.extensions.emoji.twemoji emoji_generator: !!python/name:material.extensions.emoji.to_svg From e1ee81946caf3d75dbc287e9f7751e851e0cb4ec Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Mon, 8 Dec 2025 14:20:41 +0100 Subject: [PATCH 202/230] chore: remove example bots using old balatrobot python client --- bots/example.py | 45 ------------- bots/replay.py | 170 ------------------------------------------------ 2 files changed, 215 deletions(-) delete mode 100644 bots/example.py delete mode 100644 bots/replay.py 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() From d80fba1a58a6642c9ef0c13e1ce30774bc6df811 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Mon, 8 Dec 2025 17:16:12 +0100 Subject: [PATCH 203/230] docs: add assets for balatrobot, balatrollm, and balatrobench --- docs/assets/balatrobench.svg | 38 ++++++++++++++++ docs/assets/balatrobot.svg | 85 ++++++++++-------------------------- docs/assets/balatrollm.svg | 49 +++++++++++++++++++++ docs/index.md | 37 +++++++++++++--- 4 files changed, 142 insertions(+), 67 deletions(-) create mode 100644 docs/assets/balatrobench.svg create mode 100644 docs/assets/balatrollm.svg 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/index.md b/docs/index.md index 9808e2c..574446c 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,7 +1,32 @@ -
- ![Image title](assets/balatrobot.svg){ width="256" } -
API for developing Balatro bots
-
+
+
+ + BalatroBot + +
+ BalatroBot
+ API for developing Balatro bots +
+
+
+ + BalatroLLM + +
+ BalatroLLM
+ Play Balatro with LLMs +
+
+
+ + BalatroBench + +
+ BalatroBench
+ Benchmark LLMs playing Balatro +
+
+
--- @@ -17,7 +42,7 @@ BalatroBot is a mod for Balatro that serves a JSON-RPC 2.0 HTTP API, exposing ga [:octicons-arrow-right-24: Installation](installation.md) -- :material-api:{ .lg .middle } __BalatroBot API__ +- :material-robot:{ .lg .middle } __BalatroBot API__ --- @@ -25,7 +50,7 @@ BalatroBot is a mod for Balatro that serves a JSON-RPC 2.0 HTTP API, exposing ga [:octicons-arrow-right-24: API](api.md) -- :material-robot:{ .lg .middle } __Contributing__ +- :material-code-tags:{ .lg .middle } __Contributing__ --- From d292aecb9eb463cf4d9de0c42e66a78121422a3d Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Mon, 8 Dec 2025 17:16:38 +0100 Subject: [PATCH 204/230] chore: remove old files from .mdformat.toml --- .mdformat.toml | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) 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/**"] From c08654999632973a013aa64a78f6936bbb3d08bf Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Mon, 8 Dec 2025 17:17:00 +0100 Subject: [PATCH 205/230] docs: setup favicon and logo for the docs --- mkdocs.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/mkdocs.yml b/mkdocs.yml index 6a88ceb..02a30d0 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -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: @@ -42,7 +44,7 @@ plugins: full_output: llms-full.txt autoclean: true nav: - - index.md + - BalatroBot: index.md - Installation: installation.md - API Reference: api.md - Contributing: contributing.md From 977a1d225b137fb3f03a595af3e5b1e9655f853a Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Mon, 8 Dec 2025 17:21:48 +0100 Subject: [PATCH 206/230] chore: remove build system from pyproject.toml --- pyproject.toml | 11 +--- uv.lock | 134 +------------------------------------------------ 2 files changed, 3 insertions(+), 142 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 6f64717..116f2e1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [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" }, @@ -11,14 +11,12 @@ authors = [ { 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] @@ -27,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"] @@ -49,7 +43,6 @@ dev = [ "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", diff --git a/uv.lock b/uv.lock index d4db93c..8ae2fdd 100644 --- a/uv.lock +++ b/uv.lock @@ -2,15 +2,6 @@ version = 1 revision = 2 requires-python = ">=3.13" -[[package]] -name = "annotated-types" -version = "0.7.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" } -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" }, -] - [[package]] name = "anyio" version = "4.12.0" @@ -49,10 +40,7 @@ wheels = [ [[package]] name = "balatrobot" version = "0.7.5" -source = { editable = "." } -dependencies = [ - { name = "pydantic" }, -] +source = { virtual = "." } [package.dev-dependencies] dev = [ @@ -62,7 +50,6 @@ dev = [ { name = "mdformat-simple-breaks" }, { name = "mkdocs-llmstxt" }, { name = "mkdocs-material" }, - { name = "mkdocstrings", extra = ["python"] }, { name = "pytest" }, { name = "pytest-cov" }, { name = "pytest-rerunfailures" }, @@ -72,7 +59,6 @@ dev = [ ] [package.metadata] -requires-dist = [{ name = "pydantic", specifier = ">=2.11.7" }] [package.metadata.requires-dev] dev = [ @@ -82,7 +68,6 @@ dev = [ { 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" }, @@ -220,18 +205,6 @@ 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" }, ] -[[package]] -name = "griffe" -version = "1.7.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama" }, -] -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" } -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" }, -] - [[package]] name = "h11" version = "0.16.0" @@ -482,20 +455,6 @@ 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" }, -] - [[package]] name = "mkdocs-get-deps" version = "0.2.0" @@ -556,42 +515,6 @@ 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" } -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" }, -] - [[package]] name = "more-itertools" version = "10.7.0" @@ -677,49 +600,6 @@ wheels = [ { 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" }, -] - [[package]] name = "pygments" version = "2.19.2" @@ -923,18 +803,6 @@ 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" }, ] -[[package]] -name = "typing-inspection" -version = "0.4.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" } -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" }, -] - [[package]] name = "urllib3" version = "2.5.0" From 3187e0ae73b89246d28d7bbf1b1dd6faa0b26f59 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Mon, 8 Dec 2025 17:22:29 +0100 Subject: [PATCH 207/230] docs: add stirby to contributors in balatrobot.json --- balatrobot.json | 1 + 1 file changed, 1 insertion(+) 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" From e471cf0d6ce65b311143143b418a16cc2ba73fd1 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Mon, 8 Dec 2025 17:22:44 +0100 Subject: [PATCH 208/230] docs: update the overview in README.md and update the main image --- README.md | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index fd53cb7..7a47543 100644 --- a/README.md +++ b/README.md @@ -4,20 +4,17 @@ GitHub release - - PyPI - Discord

-
balatrobot
-

A framework for Balatro bot development

+
balatrobot
+

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. ## 📚 Documentation From 4ae348034bf5ca317494ce94f47dfcc2b14fdd88 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Mon, 8 Dec 2025 17:27:35 +0100 Subject: [PATCH 209/230] chore: remove unused old files --- .claude/commands/commit-msg.md | 43 --------------------------- .claude/commands/commit.md | 53 ---------------------------------- .claude/commands/test.md | 3 -- .gitattributes | 2 -- .gitignore | 15 ++++++++++ .gitmodules | 0 runs/.gitkeep | 0 7 files changed, 15 insertions(+), 101 deletions(-) delete mode 100644 .claude/commands/commit-msg.md delete mode 100644 .claude/commands/commit.md delete mode 100644 .claude/commands/test.md delete mode 100644 .gitattributes delete mode 100644 .gitmodules delete mode 100644 runs/.gitkeep 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/.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/.gitignore b/.gitignore index 366f968..f82b3d9 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,18 @@ 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 +API.md +docs_old diff --git a/.gitmodules b/.gitmodules deleted file mode 100644 index e69de29..0000000 diff --git a/runs/.gitkeep b/runs/.gitkeep deleted file mode 100644 index e69de29..0000000 From 36435abb8a849b688fc624f8f92a70a1f941b1aa Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Mon, 8 Dec 2025 17:28:49 +0100 Subject: [PATCH 210/230] chore: remove .envrc.example --- .envrc.example | 9 --------- 1 file changed, 9 deletions(-) delete mode 100644 .envrc.example 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 From c8eda96d868d03f1857eb1d3e3fdd1762cb1018a Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Tue, 9 Dec 2025 09:24:35 +0100 Subject: [PATCH 211/230] ci: remove PyPI release workflow --- .github/workflows/release-pypi.yml | 29 ----------------------------- 1 file changed, 29 deletions(-) delete mode 100644 .github/workflows/release-pypi.yml 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 From 1bf0cc7d86174a6fa4bf0d9c89b00253622c8738 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Tue, 9 Dec 2025 09:36:14 +0100 Subject: [PATCH 212/230] docs: minor section rename in index.md --- docs/index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/index.md b/docs/index.md index 574446c..53e7f7c 100644 --- a/docs/index.md +++ b/docs/index.md @@ -48,7 +48,7 @@ BalatroBot is a mod for Balatro that serves a JSON-RPC 2.0 HTTP API, exposing ga Message formats, game states, methods, schema, enums and errors - [:octicons-arrow-right-24: API](api.md) + [:octicons-arrow-right-24: API Reference](api.md) - :material-code-tags:{ .lg .middle } __Contributing__ From 5fd345c1b62a38485df8bbe275a6875ec1616283 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Tue, 9 Dec 2025 09:38:12 +0100 Subject: [PATCH 213/230] docs: add docs/api.md --- .gitignore | 1 - docs/api.md | 1163 +++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 1163 insertions(+), 1 deletion(-) create mode 100644 docs/api.md diff --git a/.gitignore b/.gitignore index f82b3d9..41ecde5 100644 --- a/.gitignore +++ b/.gitignore @@ -29,5 +29,4 @@ ENDPOINT_ADD.md ENDPOINTS.md balatro.sh OPEN-RPC_SPEC.md -API.md docs_old diff --git a/docs/api.md b/docs/api.md new file mode 100644 index 0000000..42a30fc --- /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 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 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. From 34d576c9f534e499c8776247aaf7b1b76f87a6e1 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Tue, 9 Dec 2025 09:40:59 +0100 Subject: [PATCH 214/230] chore: add scripts_old to gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 41ecde5..04c1c3e 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,4 @@ ENDPOINTS.md balatro.sh OPEN-RPC_SPEC.md docs_old +scripts_old From fd5dcef3b99e8de919086ed4de64e2d1b738e946 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Tue, 9 Dec 2025 10:08:36 +0100 Subject: [PATCH 215/230] feat(scripts): add Balatro launchers for Linux, macOS, and Windows --- balatro.py | 240 ------------------- balatro.sh | 465 ------------------------------------- scripts/balatro-linux.py | 221 ++++++++++++++++++ scripts/balatro-macos.py | 155 +++++++++++++ scripts/balatro-windows.py | 199 ++++++++++++++++ 5 files changed, 575 insertions(+), 705 deletions(-) delete mode 100755 balatro.py delete mode 100755 balatro.sh create mode 100755 scripts/balatro-linux.py create mode 100755 scripts/balatro-macos.py create mode 100755 scripts/balatro-windows.py diff --git a/balatro.py b/balatro.py deleted file mode 100755 index 3028af6..0000000 --- a/balatro.py +++ /dev/null @@ -1,240 +0,0 @@ -#!/usr/bin/env python3 -"""Minimal 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 kill(port: int | None = None): - """Kill all running Balatro instances and optionally processes on a specific port.""" - if port: - print(f"Killing processes on port {port}...") - # Find processes listening on the 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} on port {port}") - subprocess.run(["kill", "-9", pid], stderr=subprocess.DEVNULL) - time.sleep(0.5) - - print("Killing all 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) - print("Done.") - - -def status(): - """Show running Balatro instances with ports.""" - # Find Balatro processes - result = subprocess.run( - ["ps", "aux"], - capture_output=True, - text=True, - ) - - balatro_pids = [] - for line in result.stdout.splitlines(): - if "Balatro.app" in line and "grep" not in line: - parts = line.split() - if len(parts) > 1: - balatro_pids.append(parts[1]) - - if not balatro_pids: - print("No Balatro instances running") - return - - # Find ports for each PID - for pid in balatro_pids: - result = subprocess.run( - ["lsof", "-Pan", "-p", pid, "-i", "TCP"], - stdout=subprocess.PIPE, - stderr=subprocess.DEVNULL, - text=True, - ) - - port = None - for line in result.stdout.splitlines(): - if "LISTEN" in line: - parts = line.split() - for part in parts: - if ":" in part: - port = part.split(":")[-1] - break - if port: - break - - if port: - log_file = LOGS_DIR / f"balatro_{port}.log" - print(f"Port {port}, PID {pid}, Log: {log_file}") - - -def start(args): - """Start Balatro with given configuration.""" - # Kill processes on the specified port - print(f"Killing processes on port {args.port}...") - result = subprocess.run( - ["lsof", "-ti", f":{args.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} on port {args.port}") - subprocess.run(["kill", "-9", pid], stderr=subprocess.DEVNULL) - time.sleep(0.5) - - # Kill existing Balatro instances - subprocess.run(["pkill", "-f", "Balatro\\.app"], stderr=subprocess.DEVNULL) - time.sleep(1) - - # 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 - log_file = LOGS_DIR / f"balatro_{args.port}.log" - with open(log_file, "w") as log: - # Start Balatro - process = subprocess.Popen( - [str(BALATRO_EXE)], - env=env, - stdout=log, - stderr=subprocess.STDOUT, - ) - - time.sleep(1) - # Move back to workspace 3 (code editor) - subprocess.Popen( - "command -v aerospace >/dev/null 2>&1 && aerospace workspace 3", - shell=True, - ) - - # Verify it started - time.sleep(3) - if process.poll() is not None: - print(f"ERROR: Balatro failed to start. Check {log_file}") - sys.exit(1) - - print(f"Port {args.port}, PID {process.pid}, Log: {log_file}") - - -def main(): - parser = argparse.ArgumentParser(description="Balatro launcher") - - subparsers = parser.add_subparsers(dest="command", help="Command to run") - - # Start command - start_parser = subparsers.add_parser( - "start", - help="Start Balatro (default)", - ) - start_parser.add_argument( - "--host", - default="127.0.0.1", - help="Server host (default: 127.0.0.1)", - ) - start_parser.add_argument( - "--port", - type=int, - default=12346, - help="Server port (default: 12346)", - ) - start_parser.add_argument( - "--headless", - action="store_true", - help="Run in headless mode", - ) - start_parser.add_argument( - "--fast", - action="store_true", - help="Run in fast mode", - ) - start_parser.add_argument( - "--render-on-api", - action="store_true", - help="Render only on API calls", - ) - start_parser.add_argument( - "--audio", - action="store_true", - help="Enable audio", - ) - start_parser.add_argument( - "--debug", - action="store_true", - help="Enable debug mode (requires DebugPlus mod, loads test endpoints)", - ) - start_parser.add_argument( - "--no-shaders", - action="store_true", - help="Disable all shaders for better performance", - ) - - # Kill command - kill_parser = subparsers.add_parser( - "kill", - help="Kill all Balatro instances", - ) - kill_parser.add_argument( - "--port", - type=int, - help="Also kill processes on this port", - ) - - # Status command - subparsers.add_parser( - "status", - help="Show running instances", - ) - - args = parser.parse_args() - - # Execute command - if args.command == "kill": - kill(args.port if hasattr(args, "port") else None) - elif args.command == "status": - status() - elif args.command == "start": - start(args) - - -if __name__ == "__main__": - main() 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/scripts/balatro-linux.py b/scripts/balatro-linux.py new file mode 100755 index 0000000..abc4f73 --- /dev/null +++ b/scripts/balatro-linux.py @@ -0,0 +1,221 @@ +#!/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 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 and verify process started + time.sleep(3) + if process.poll() is not None: + print(f"ERROR: Balatro failed to start. Check {log_file}") + sys.exit(1) + + print(f"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..1390b92 --- /dev/null +++ b/scripts/balatro-macos.py @@ -0,0 +1,155 @@ +#!/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 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 and verify process started + time.sleep(3) + if process.poll() is not None: + print(f"ERROR: Balatro failed to start. Check {log_file}") + sys.exit(1) + + print(f"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..c00e076 --- /dev/null +++ b/scripts/balatro-windows.py @@ -0,0 +1,199 @@ +#!/usr/bin/env python3 +"""Balatro launcher for Windows.""" + +import argparse +import os +import re +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 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 and verify process started + time.sleep(3) + if process.poll() is not None: + print(f"ERROR: Balatro failed to start. Check {log_file}") + sys.exit(1) + + print(f"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() From 0894fdb0c8654f61ff99fcef9fbbe5591d4d698c Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Tue, 9 Dec 2025 10:08:50 +0100 Subject: [PATCH 216/230] chore: update gitignore with smods.wiki and remove scripts --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 04c1c3e..8238ca8 100644 --- a/.gitignore +++ b/.gitignore @@ -5,7 +5,6 @@ __pycache__ runs/*.jsonl src/lua_old balatrobot_old.lua -scripts dump coverage.xml .coverage @@ -31,3 +30,4 @@ balatro.sh OPEN-RPC_SPEC.md docs_old scripts_old +smods.wiki From 2b3eea499bd6c07e7bbc4d5095cb5b087d35fa18 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Tue, 9 Dec 2025 10:10:15 +0100 Subject: [PATCH 217/230] docs: update installation guide with new launcher scripts --- docs/installation.md | 79 ++++++++++++++++++++++++++++++++++---------- 1 file changed, 61 insertions(+), 18 deletions(-) diff --git a/docs/installation.md b/docs/installation.md index 5f87ee2..ee2680b 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -6,7 +6,7 @@ This guide covers installing the BalatroBot mod for Balatro. 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/Steamopollys/Steamodded#installation) +3. **Steamodded** - Follow the [installation guide](https://github.com/Steamodded/smods/wiki) ## Mod Installation @@ -33,29 +33,41 @@ balatrobot/ | macOS | `~/Library/Application Support/Balatro/Mods/balatrobot/` | | Linux | `~/.local/share/Steam/steamapps/compatdata/2379780/pfx/drive_c/users/steamuser/AppData/Roaming/Balatro/Mods/` | -### 3. Configure (Optional) +### 3. Launch Balatro -BalatroBot reads configuration from environment variables. Set these before launching Balatro: +Use the platform-specific launcher script from the `scripts/` directory: -| 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) | +```bash +# macOS +python scripts/balatro-macos.py --fast --debug -Example (bash): +# Linux (via Proton) +python scripts/balatro-linux.py --fast --debug -```bash -export BALATROBOT_PORT=12346 -export BALATROBOT_FAST=1 -# Then launch Balatro +# Windows +python scripts/balatro-windows.py --fast --debug ``` +**Available options:** + +| 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 | + +The scripts automatically: + +- Kill any existing Balatro instances +- Kill processes using the specified port +- Set up the correct environment variables +- Log output to `logs/balatro_{port}.log` + ### 4. Verify Installation Start Balatro, then test the connection: @@ -77,3 +89,34 @@ Expected response: - **Connection refused**: Ensure Balatro is running and the mod loaded successfully - **Mod not loading**: Check that Lovely and Steamodded are installed correctly - **Port in use**: Change `BALATROBOT_PORT` to a different value + +## Custom Launchers + +If you're using a custom launcher or need to start Balatro manually, set these environment variables before launching: + +| 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) | + +**Example (bash):** + +```bash +export BALATROBOT_PORT=12346 +export BALATROBOT_FAST=1 +# Then launch Balatro with the lovely injector +``` + +**Example (Windows PowerShell):** + +```powershell +$env:BALATROBOT_PORT = "12346" +$env:BALATROBOT_FAST = "1" +# Then launch Balatro.exe +``` From c50fd03020e1afd09e482c266bf076721a98df4c Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Tue, 9 Dec 2025 10:10:27 +0100 Subject: [PATCH 218/230] feat: use balatro launcher script based on OS in Makefile --- Makefile | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/Makefile b/Makefile index eea09b3..3bcb41a 100644 --- a/Makefile +++ b/Makefile @@ -11,6 +11,16 @@ RESET := \033[0m # Test variables PYTEST_MARKER ?= +# 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)" @echo "" @@ -44,13 +54,13 @@ quality: lint typecheck format ## Run all code quality checks fixtures: ## Generate fixtures @echo "$(YELLOW)Starting Balatro...$(RESET)" - python balatro.py start --fast --debug + $(BALATRO_SCRIPT) --fast --debug @echo "$(YELLOW)Generating all fixtures...$(RESET)" python tests/fixtures/generate.py test: ## Run tests head-less @echo "$(YELLOW)Starting Balatro...$(RESET)" - python balatro.py start --fast --debug + $(BALATRO_SCRIPT) --fast --debug @echo "$(YELLOW)Running tests...$(RESET)" pytest tests/lua $(if $(PYTEST_MARKER),-m "$(PYTEST_MARKER)") -v -s From abe7cf4c853d8afdf34d269570124e047914680f Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Tue, 9 Dec 2025 10:10:58 +0100 Subject: [PATCH 219/230] docs: add python syntax highlighting --- mkdocs.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/mkdocs.yml b/mkdocs.yml index 02a30d0..65c9388 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -57,10 +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 - pymdownx.emoji: emoji_index: !!python/name:material.extensions.emoji.twemoji emoji_generator: !!python/name:material.extensions.emoji.to_svg From 452dff24b35571b7fcca4db20e078688ee2460d5 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Tue, 9 Dec 2025 10:13:03 +0100 Subject: [PATCH 220/230] style(scripts): fix linter errors --- scripts/balatro-linux.py | 2 +- scripts/balatro-macos.py | 2 +- scripts/balatro-windows.py | 3 +-- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/scripts/balatro-linux.py b/scripts/balatro-linux.py index abc4f73..804f8d0 100755 --- a/scripts/balatro-linux.py +++ b/scripts/balatro-linux.py @@ -162,7 +162,7 @@ def start(args): print(f"ERROR: Balatro failed to start. Check {log_file}") sys.exit(1) - print(f"Balatro started successfully!") + print("Balatro started successfully!") print(f" Port: {args.port}") print(f" PID: {process.pid}") print(f" Log: {log_file}") diff --git a/scripts/balatro-macos.py b/scripts/balatro-macos.py index 1390b92..63df3de 100755 --- a/scripts/balatro-macos.py +++ b/scripts/balatro-macos.py @@ -96,7 +96,7 @@ def start(args): print(f"ERROR: Balatro failed to start. Check {log_file}") sys.exit(1) - print(f"Balatro started successfully!") + print("Balatro started successfully!") print(f" Port: {args.port}") print(f" PID: {process.pid}") print(f" Log: {log_file}") diff --git a/scripts/balatro-windows.py b/scripts/balatro-windows.py index c00e076..10ca7a9 100755 --- a/scripts/balatro-windows.py +++ b/scripts/balatro-windows.py @@ -3,7 +3,6 @@ import argparse import os -import re import subprocess import sys import time @@ -140,7 +139,7 @@ def start(args): print(f"ERROR: Balatro failed to start. Check {log_file}") sys.exit(1) - print(f"Balatro started successfully!") + print("Balatro started successfully!") print(f" Port: {args.port}") print(f" PID: {process.pid}") print(f" Log: {log_file}") From 987de257d3013398ee6967ac03850db7aaec2ae5 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Tue, 9 Dec 2025 10:20:22 +0100 Subject: [PATCH 221/230] docs: fix formatting for contributing.md --- docs/contributing.md | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/docs/contributing.md b/docs/contributing.md index 9abd1bd..9cccad0 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -23,16 +23,19 @@ cd balatrobot Instead of copying files, create a symlink for easier development: **macOS:** + ```bash ln -s "$(pwd)" ~/Library/Application\ Support/Balatro/Mods/balatrobot ``` **Linux:** + ```bash ln -s "$(pwd)" ~/.local/share/Steam/steamapps/compatdata/2379780/pfx/drive_c/users/steamuser/AppData/Roaming/Balatro/Mods/ ``` **Windows (PowerShell as Admin):** + ```powershell New-Item -ItemType SymbolicLink -Path "$env:APPDATA\Balatro\Mods\balatrobot" -Target (Get-Location) ``` @@ -89,7 +92,7 @@ src/lua/ ## Adding a New Endpoint -1. Create `src/lua/endpoints/your_endpoint.lua`: +- Create `src/lua/endpoints/your_endpoint.lua`: ```lua return { @@ -110,9 +113,13 @@ return { } ``` -2. Add tests in `tests/lua/endpoints/test_your_endpoint.py` +- Add tests in `tests/lua/endpoints/test_your_endpoint.py` + +> When writing tests for new endpoints, you can mark the `@pytest.mark.dev` decorator to only run the test you are developing with `make test PYTEST_MARKER=dev`. + +- Update `src/lua/utils/openrpc.json` with the new method -3. Update `src/lua/utils/openrpc.json` with the new method +- Update `docs/api.md` with the new method ## Pull Request Guidelines From 369372c59fbd9c3a0c014cb4e7e9bf1707566188 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Tue, 9 Dec 2025 10:21:23 +0100 Subject: [PATCH 222/230] docs: fix minor english issues --- docs/api.md | 2 +- docs/contributing.md | 2 +- docs/installation.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/api.md b/docs/api.md index 42a30fc..379419f 100644 --- a/docs/api.md +++ b/docs/api.md @@ -964,7 +964,7 @@ Persistent cards that provide scoring bonuses, triggered abilities, or passive e | `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 card held in hand abilities | +| `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 | diff --git a/docs/contributing.md b/docs/contributing.md index 9cccad0..255e627 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -115,7 +115,7 @@ return { - Add tests in `tests/lua/endpoints/test_your_endpoint.py` -> When writing tests for new endpoints, you can mark the `@pytest.mark.dev` decorator to only run the test you are developing with `make test PYTEST_MARKER=dev`. +> 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`. - Update `src/lua/utils/openrpc.json` with the new method diff --git a/docs/installation.md b/docs/installation.md index ee2680b..a2747fb 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -110,7 +110,7 @@ If you're using a custom launcher or need to start Balatro manually, set these e ```bash export BALATROBOT_PORT=12346 export BALATROBOT_FAST=1 -# Then launch Balatro with the lovely injector +# Then launch Balatro with the Lovely Injector ``` **Example (Windows PowerShell):** From 97a13ce19af48c8e466e94683914a421a57365fa Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Tue, 9 Dec 2025 10:22:31 +0100 Subject: [PATCH 223/230] chore: remove old tests for balatrobot python client --- tests/balatrobot/conftest.py | 21 -- tests/balatrobot/test_client.py | 497 ---------------------------- tests/balatrobot/test_exceptions.py | 90 ----- tests/balatrobot/test_models.py | 30 -- 4 files changed, 638 deletions(-) delete mode 100644 tests/balatrobot/conftest.py delete mode 100644 tests/balatrobot/test_client.py delete mode 100644 tests/balatrobot/test_exceptions.py delete mode 100644 tests/balatrobot/test_models.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 From 89cbda9a7fbebe353f3ff2ce24503e22203416d1 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Tue, 9 Dec 2025 11:23:45 +0100 Subject: [PATCH 224/230] feat(lua.utils): add CardKey schema to openrpc.json --- src/lua/utils/openrpc.json | 355 ++++++++++++++++++++++++++++++++++++- 1 file changed, 354 insertions(+), 1 deletion(-) diff --git a/src/lua/utils/openrpc.json b/src/lua/utils/openrpc.json index 69d5865..28e2afc 100644 --- a/src/lua/utils/openrpc.json +++ b/src/lua/utils/openrpc.json @@ -49,7 +49,7 @@ "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": { - "type": "string" + "$ref": "#/components/schemas/CardKey" } }, { @@ -1418,6 +1418,359 @@ "SKIPPED", "SELECT" ] + }, + "TarotKey": { + "type": "string", + "description": "Tarot consumable card key", + "enum": [ + "c_fool", + "c_magician", + "c_high_priestess", + "c_empress", + "c_emperor", + "c_heirophant", + "c_lovers", + "c_chariot", + "c_justice", + "c_hermit", + "c_wheel_of_fortune", + "c_strength", + "c_hanged_man", + "c_death", + "c_temperance", + "c_devil", + "c_tower", + "c_star", + "c_moon", + "c_sun", + "c_judgement", + "c_world" + ] + }, + "PlanetKey": { + "type": "string", + "description": "Planet consumable card key", + "enum": [ + "c_mercury", + "c_venus", + "c_earth", + "c_mars", + "c_jupiter", + "c_saturn", + "c_uranus", + "c_neptune", + "c_pluto", + "c_planet_x", + "c_ceres", + "c_eris" + ] + }, + "SpectralKey": { + "type": "string", + "description": "Spectral consumable card key", + "enum": [ + "c_familiar", + "c_grim", + "c_incantation", + "c_talisman", + "c_aura", + "c_wraith", + "c_sigil", + "c_ouija", + "c_ectoplasm", + "c_immolate", + "c_ankh", + "c_deja_vu", + "c_hex", + "c_trance", + "c_medium", + "c_cryptid", + "c_soul", + "c_black_hole" + ] + }, + "JokerKey": { + "type": "string", + "description": "Joker card key", + "enum": [ + "j_joker", + "j_greedy_joker", + "j_lusty_joker", + "j_wrathful_joker", + "j_gluttenous_joker", + "j_jolly", + "j_zany", + "j_mad", + "j_crazy", + "j_droll", + "j_sly", + "j_wily", + "j_clever", + "j_devious", + "j_crafty", + "j_half", + "j_stencil", + "j_four_fingers", + "j_mime", + "j_credit_card", + "j_ceremonial", + "j_banner", + "j_mystic_summit", + "j_marble", + "j_loyalty_card", + "j_8_ball", + "j_misprint", + "j_dusk", + "j_raised_fist", + "j_chaos", + "j_fibonacci", + "j_steel_joker", + "j_scary_face", + "j_abstract", + "j_delayed_grat", + "j_hack", + "j_pareidolia", + "j_gros_michel", + "j_even_steven", + "j_odd_todd", + "j_scholar", + "j_business", + "j_supernova", + "j_ride_the_bus", + "j_space", + "j_egg", + "j_burglar", + "j_blackboard", + "j_runner", + "j_ice_cream", + "j_dna", + "j_splash", + "j_blue_joker", + "j_sixth_sense", + "j_constellation", + "j_hiker", + "j_faceless", + "j_green_joker", + "j_superposition", + "j_todo_list", + "j_cavendish", + "j_card_sharp", + "j_red_card", + "j_madness", + "j_square", + "j_seance", + "j_riff_raff", + "j_vampire", + "j_shortcut", + "j_hologram", + "j_vagabond", + "j_baron", + "j_cloud_9", + "j_rocket", + "j_obelisk", + "j_midas_mask", + "j_luchador", + "j_photograph", + "j_gift", + "j_turtle_bean", + "j_erosion", + "j_reserved_parking", + "j_mail", + "j_to_the_moon", + "j_hallucination", + "j_fortune_teller", + "j_juggler", + "j_drunkard", + "j_stone", + "j_golden", + "j_lucky_cat", + "j_baseball", + "j_bull", + "j_diet_cola", + "j_trading", + "j_flash", + "j_popcorn", + "j_trousers", + "j_ancient", + "j_ramen", + "j_walkie_talkie", + "j_selzer", + "j_castle", + "j_smiley", + "j_campfire", + "j_ticket", + "j_mr_bones", + "j_acrobat", + "j_sock_and_buskin", + "j_swashbuckler", + "j_troubadour", + "j_certificate", + "j_smeared", + "j_throwback", + "j_hanging_chad", + "j_rough_gem", + "j_bloodstone", + "j_arrowhead", + "j_onyx_agate", + "j_glass", + "j_ring_master", + "j_flower_pot", + "j_blueprint", + "j_wee", + "j_merry_andy", + "j_oops", + "j_idol", + "j_seeing_double", + "j_matador", + "j_hit_the_road", + "j_duo", + "j_trio", + "j_family", + "j_order", + "j_tribe", + "j_stuntman", + "j_invisible", + "j_brainstorm", + "j_satellite", + "j_shoot_the_moon", + "j_drivers_license", + "j_cartomancer", + "j_astronomer", + "j_burnt", + "j_bootstraps", + "j_caino", + "j_triboulet", + "j_yorick", + "j_chicot", + "j_perkeo" + ] + }, + "VoucherKey": { + "type": "string", + "description": "Voucher card key", + "enum": [ + "v_overstock_norm", + "v_clearance_sale", + "v_hone", + "v_reroll_surplus", + "v_crystal_ball", + "v_telescope", + "v_grabber", + "v_wasteful", + "v_tarot_merchant", + "v_planet_merchant", + "v_seed_money", + "v_blank", + "v_magic_trick", + "v_hieroglyph", + "v_directors_cut", + "v_paint_brush", + "v_overstock_plus", + "v_liquidation", + "v_glow_up", + "v_reroll_glut", + "v_omen_globe", + "v_observatory", + "v_nacho_tong", + "v_recyclomancy", + "v_tarot_tycoon", + "v_planet_tycoon", + "v_money_tree", + "v_antimatter", + "v_illusion", + "v_petroglyph", + "v_retcon", + "v_palette" + ] + }, + "PlayingCardKey": { + "type": "string", + "description": "Playing card key in SUIT_RANK format (H=Hearts, D=Diamonds, C=Clubs, S=Spades)", + "enum": [ + "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" + ] + }, + "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": { From 78812f710c5d9c88fe63b7e6fccfbc94e5f53742 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Tue, 9 Dec 2025 11:39:53 +0100 Subject: [PATCH 225/230] feat(lua.utils): add descriptions to openrpc.json enums --- src/lua/utils/openrpc.json | 1924 ++++++++++++++++++++++++++++-------- 1 file changed, 1513 insertions(+), 411 deletions(-) diff --git a/src/lua/utils/openrpc.json b/src/lua/utils/openrpc.json index 28e2afc..49f1159 100644 --- a/src/lua/utils/openrpc.json +++ b/src/lua/utils/openrpc.json @@ -1267,478 +1267,1580 @@ ] }, "State": { - "type": "string", "description": "Game state enumeration", - "enum": [ - "MENU", - "BLIND_SELECT", - "SELECTING_HAND", - "HAND_PLAYED", - "DRAW_TO_HAND", - "NEW_ROUND", - "ROUND_EVAL", - "SHOP", - "PLAY_TAROT", - "TAROT_PACK", - "PLANET_PACK", - "SPECTRAL_PACK", - "STANDARD_PACK", - "BUFFOON_PACK", - "GAME_OVER" + "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": { - "type": "string", - "description": "Deck enumeration", - "enum": [ - "RED", - "BLUE", - "YELLOW", - "GREEN", - "BLACK", - "MAGIC", - "NEBULA", - "GHOST", - "ABANDONED", - "CHECKERED", - "ZODIAC", - "PAINTED", - "ANAGLYPH", - "PLASMA", - "ERRATIC" + "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": { - "type": "string", - "description": "Stake level enumeration", - "enum": [ - "WHITE", - "RED", - "GREEN", - "BLACK", - "BLUE", - "PURPLE", - "ORANGE", - "GOLD" + "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": { - "type": "string", "description": "Card suit", - "enum": [ - "H", - "D", - "C", - "S" + "oneOf": [ + { + "const": "H", + "description": "Hearts" + }, + { + "const": "D", + "description": "Diamonds" + }, + { + "const": "C", + "description": "Clubs" + }, + { + "const": "S", + "description": "Spades" + } ] }, "Rank": { - "type": "string", "description": "Card rank", - "enum": [ - "2", - "3", - "4", - "5", - "6", - "7", - "8", - "9", - "T", - "J", - "Q", - "K", - "A" + "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": { - "type": "string", "description": "Card seal type", - "enum": [ - "RED", - "BLUE", - "GOLD", - "PURPLE" + "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": { - "type": "string", "description": "Card edition type", - "enum": [ - "HOLO", - "FOIL", - "POLYCHROME", - "NEGATIVE" + "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": { - "type": "string", "description": "Card enhancement type", - "enum": [ - "BONUS", - "MULT", - "WILD", - "GLASS", - "STEEL", - "STONE", - "GOLD", - "LUCKY" + "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": { - "type": "string", "description": "Card set/type", - "enum": [ - "DEFAULT", - "ENHANCED", - "JOKER", - "TAROT", - "PLANET", - "SPECTRAL", - "VOUCHER", - "BOOSTER" + "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": { - "type": "string", "description": "Blind type", - "enum": [ - "SMALL", - "BIG", - "BOSS" + "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": { - "type": "string", "description": "Blind status", - "enum": [ - "UPCOMING", - "CURRENT", - "DEFEATED", - "SKIPPED", - "SELECT" + "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": { - "type": "string", "description": "Tarot consumable card key", - "enum": [ - "c_fool", - "c_magician", - "c_high_priestess", - "c_empress", - "c_emperor", - "c_heirophant", - "c_lovers", - "c_chariot", - "c_justice", - "c_hermit", - "c_wheel_of_fortune", - "c_strength", - "c_hanged_man", - "c_death", - "c_temperance", - "c_devil", - "c_tower", - "c_star", - "c_moon", - "c_sun", - "c_judgement", - "c_world" + "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": { - "type": "string", "description": "Planet consumable card key", - "enum": [ - "c_mercury", - "c_venus", - "c_earth", - "c_mars", - "c_jupiter", - "c_saturn", - "c_uranus", - "c_neptune", - "c_pluto", - "c_planet_x", - "c_ceres", - "c_eris" + "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": { - "type": "string", "description": "Spectral consumable card key", - "enum": [ - "c_familiar", - "c_grim", - "c_incantation", - "c_talisman", - "c_aura", - "c_wraith", - "c_sigil", - "c_ouija", - "c_ectoplasm", - "c_immolate", - "c_ankh", - "c_deja_vu", - "c_hex", - "c_trance", - "c_medium", - "c_cryptid", - "c_soul", - "c_black_hole" - ] + "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": { - "type": "string", "description": "Joker card key", - "enum": [ - "j_joker", - "j_greedy_joker", - "j_lusty_joker", - "j_wrathful_joker", - "j_gluttenous_joker", - "j_jolly", - "j_zany", - "j_mad", - "j_crazy", - "j_droll", - "j_sly", - "j_wily", - "j_clever", - "j_devious", - "j_crafty", - "j_half", - "j_stencil", - "j_four_fingers", - "j_mime", - "j_credit_card", - "j_ceremonial", - "j_banner", - "j_mystic_summit", - "j_marble", - "j_loyalty_card", - "j_8_ball", - "j_misprint", - "j_dusk", - "j_raised_fist", - "j_chaos", - "j_fibonacci", - "j_steel_joker", - "j_scary_face", - "j_abstract", - "j_delayed_grat", - "j_hack", - "j_pareidolia", - "j_gros_michel", - "j_even_steven", - "j_odd_todd", - "j_scholar", - "j_business", - "j_supernova", - "j_ride_the_bus", - "j_space", - "j_egg", - "j_burglar", - "j_blackboard", - "j_runner", - "j_ice_cream", - "j_dna", - "j_splash", - "j_blue_joker", - "j_sixth_sense", - "j_constellation", - "j_hiker", - "j_faceless", - "j_green_joker", - "j_superposition", - "j_todo_list", - "j_cavendish", - "j_card_sharp", - "j_red_card", - "j_madness", - "j_square", - "j_seance", - "j_riff_raff", - "j_vampire", - "j_shortcut", - "j_hologram", - "j_vagabond", - "j_baron", - "j_cloud_9", - "j_rocket", - "j_obelisk", - "j_midas_mask", - "j_luchador", - "j_photograph", - "j_gift", - "j_turtle_bean", - "j_erosion", - "j_reserved_parking", - "j_mail", - "j_to_the_moon", - "j_hallucination", - "j_fortune_teller", - "j_juggler", - "j_drunkard", - "j_stone", - "j_golden", - "j_lucky_cat", - "j_baseball", - "j_bull", - "j_diet_cola", - "j_trading", - "j_flash", - "j_popcorn", - "j_trousers", - "j_ancient", - "j_ramen", - "j_walkie_talkie", - "j_selzer", - "j_castle", - "j_smiley", - "j_campfire", - "j_ticket", - "j_mr_bones", - "j_acrobat", - "j_sock_and_buskin", - "j_swashbuckler", - "j_troubadour", - "j_certificate", - "j_smeared", - "j_throwback", - "j_hanging_chad", - "j_rough_gem", - "j_bloodstone", - "j_arrowhead", - "j_onyx_agate", - "j_glass", - "j_ring_master", - "j_flower_pot", - "j_blueprint", - "j_wee", - "j_merry_andy", - "j_oops", - "j_idol", - "j_seeing_double", - "j_matador", - "j_hit_the_road", - "j_duo", - "j_trio", - "j_family", - "j_order", - "j_tribe", - "j_stuntman", - "j_invisible", - "j_brainstorm", - "j_satellite", - "j_shoot_the_moon", - "j_drivers_license", - "j_cartomancer", - "j_astronomer", - "j_burnt", - "j_bootstraps", - "j_caino", - "j_triboulet", - "j_yorick", - "j_chicot", - "j_perkeo" + "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": { - "type": "string", "description": "Voucher card key", - "enum": [ - "v_overstock_norm", - "v_clearance_sale", - "v_hone", - "v_reroll_surplus", - "v_crystal_ball", - "v_telescope", - "v_grabber", - "v_wasteful", - "v_tarot_merchant", - "v_planet_merchant", - "v_seed_money", - "v_blank", - "v_magic_trick", - "v_hieroglyph", - "v_directors_cut", - "v_paint_brush", - "v_overstock_plus", - "v_liquidation", - "v_glow_up", - "v_reroll_glut", - "v_omen_globe", - "v_observatory", - "v_nacho_tong", - "v_recyclomancy", - "v_tarot_tycoon", - "v_planet_tycoon", - "v_money_tree", - "v_antimatter", - "v_illusion", - "v_petroglyph", - "v_retcon", - "v_palette" + "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": { - "type": "string", "description": "Playing card key in SUIT_RANK format (H=Hearts, D=Diamonds, C=Clubs, S=Spades)", - "enum": [ - "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" + "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": { @@ -1804,4 +2906,4 @@ } } } -} +} \ No newline at end of file From 7be30b43de63c608dbc6ed535b2a7b47d64328d0 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Wed, 10 Dec 2025 08:44:46 +0100 Subject: [PATCH 226/230] docs: add related projects and v1 warning --- README.md | 42 ++++++++++++++++++++++++++++++++++ docs/index.md | 63 +++++++++++++++++++++++++++++++++++---------------- 2 files changed, 86 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 7a47543..a5ce7c7 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,16 @@ 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 https://coder.github.io/balatrobot/ @@ -29,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 + +
+
+ + BalatroBot + +
+ BalatroBot
+ API for developing Balatro bots +
+
+
+ + BalatroLLM + +
+ BalatroLLM
+ Play Balatro with LLMs +
+
+
+ + BalatroBench + +
+ BalatroBench
+ Benchmark LLMs playing Balatro +
+
+
diff --git a/docs/index.md b/docs/index.md index 53e7f7c..7e9704d 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,4 +1,4 @@ -
+
BalatroBot @@ -8,30 +8,23 @@ API for developing Balatro bots
-
- - BalatroLLM - -
- BalatroLLM
- Play Balatro with LLMs -
-
-
- - BalatroBench - -
- BalatroBench
- Benchmark LLMs playing Balatro -
-
--- 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. +
- :material-download:{ .lg .middle } __Installation__ @@ -67,3 +60,35 @@ BalatroBot is a mod for Balatro that serves a JSON-RPC 2.0 HTTP API, exposing ga [:octicons-arrow-right-24: llms-full.txt](llms-full.txt)
+ +## Related Projects + +
+
+ + BalatroBot + +
+ BalatroBot
+ API for developing Balatro bots +
+
+
+ + BalatroLLM + +
+ BalatroLLM
+ Play Balatro with LLMs +
+
+
+ + BalatroBench + +
+ BalatroBench
+ Benchmark LLMs playing Balatro +
+
+
From 7823086fc3a882ddd0d00776bc7c079b5bea3231 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Thu, 11 Dec 2025 17:13:41 +0100 Subject: [PATCH 227/230] refactor(fixtures): change endpoint to method and arguments to params --- tests/fixtures/fixtures.json | 1214 +++++++++++++-------------- tests/fixtures/fixtures.schema.json | 29 - 2 files changed, 607 insertions(+), 636 deletions(-) delete mode 100644 tests/fixtures/fixtures.schema.json diff --git a/tests/fixtures/fixtures.json b/tests/fixtures/fixtures.json index 7c5c525..a5e9673 100644 --- a/tests/fixtures/fixtures.json +++ b/tests/fixtures/fixtures.json @@ -1,14 +1,14 @@ { - "$schema": "./fixtures.schema.json", + "$schema": "https://gist.githubusercontent.com/S1M0N38/f0fafbc76e1b057820533582276d7fec/raw/a7d0937b79351945022ec3f254355560bb222930/fixtures.schema.json", "health": { "state-BLIND_SELECT": [ { - "endpoint": "menu", - "arguments": {} + "method": "menu", + "params": {} }, { - "endpoint": "start", - "arguments": { + "method": "start", + "params": { "deck": "RED", "stake": "WHITE", "seed": "TEST123" @@ -19,12 +19,12 @@ "gamestate": { "state-BLIND_SELECT--round_num-0--deck-RED--stake-WHITE": [ { - "endpoint": "menu", - "arguments": {} + "method": "menu", + "params": {} }, { - "endpoint": "start", - "arguments": { + "method": "start", + "params": { "deck": "RED", "stake": "WHITE", "seed": "TEST123" @@ -35,12 +35,12 @@ "save": { "state-BLIND_SELECT": [ { - "endpoint": "menu", - "arguments": {} + "method": "menu", + "params": {} }, { - "endpoint": "start", - "arguments": { + "method": "start", + "params": { "deck": "RED", "stake": "WHITE", "seed": "TEST123" @@ -51,12 +51,12 @@ "load": { "state-BLIND_SELECT": [ { - "endpoint": "menu", - "arguments": {} + "method": "menu", + "params": {} }, { - "endpoint": "start", - "arguments": { + "method": "start", + "params": { "deck": "RED", "stake": "WHITE", "seed": "TEST123" @@ -67,68 +67,68 @@ "set": { "state-SELECTING_HAND": [ { - "endpoint": "menu", - "arguments": {} + "method": "menu", + "params": {} }, { - "endpoint": "start", - "arguments": { + "method": "start", + "params": { "deck": "RED", "stake": "WHITE", "seed": "TEST123" } }, { - "endpoint": "select", - "arguments": {} + "method": "select", + "params": {} } ], "state-SHOP": [ { - "endpoint": "menu", - "arguments": {} + "method": "menu", + "params": {} }, { - "endpoint": "start", - "arguments": { + "method": "start", + "params": { "deck": "RED", "stake": "WHITE", "seed": "TEST123" } }, { - "endpoint": "select", - "arguments": {} + "method": "select", + "params": {} }, { - "endpoint": "set", - "arguments": { + "method": "set", + "params": { "chips": 1000 } }, { - "endpoint": "play", - "arguments": { + "method": "play", + "params": { "cards": [ 0 ] } }, { - "endpoint": "cash_out", - "arguments": {} + "method": "cash_out", + "params": {} } ] }, "menu": { "state-BLIND_SELECT": [ { - "endpoint": "menu", - "arguments": {} + "method": "menu", + "params": {} }, { - "endpoint": "start", - "arguments": { + "method": "start", + "params": { "deck": "RED", "stake": "WHITE", "seed": "TEST123" @@ -139,12 +139,12 @@ "start": { "state-BLIND_SELECT": [ { - "endpoint": "menu", - "arguments": {} + "method": "menu", + "params": {} }, { - "endpoint": "start", - "arguments": { + "method": "start", + "params": { "deck": "RED", "stake": "WHITE", "seed": "TEST123" @@ -155,12 +155,12 @@ "skip": { "state-BLIND_SELECT--blinds.small.status-SELECT": [ { - "endpoint": "menu", - "arguments": {} + "method": "menu", + "params": {} }, { - "endpoint": "start", - "arguments": { + "method": "start", + "params": { "deck": "RED", "stake": "WHITE", "seed": "TEST123" @@ -169,54 +169,54 @@ ], "state-BLIND_SELECT--blinds.big.status-SELECT": [ { - "endpoint": "menu", - "arguments": {} + "method": "menu", + "params": {} }, { - "endpoint": "start", - "arguments": { + "method": "start", + "params": { "deck": "RED", "stake": "WHITE", "seed": "TEST123" } }, { - "endpoint": "skip", - "arguments": {} + "method": "skip", + "params": {} } ], "state-BLIND_SELECT--blinds.boss.status-SELECT": [ { - "endpoint": "menu", - "arguments": {} + "method": "menu", + "params": {} }, { - "endpoint": "start", - "arguments": { + "method": "start", + "params": { "deck": "RED", "stake": "WHITE", "seed": "TEST123" } }, { - "endpoint": "skip", - "arguments": {} + "method": "skip", + "params": {} }, { - "endpoint": "skip", - "arguments": {} + "method": "skip", + "params": {} } ] }, "select": { "state-BLIND_SELECT--blinds.small.status-SELECT": [ { - "endpoint": "menu", - "arguments": {} + "method": "menu", + "params": {} }, { - "endpoint": "start", - "arguments": { + "method": "start", + "params": { "deck": "RED", "stake": "WHITE", "seed": "TEST123" @@ -225,54 +225,54 @@ ], "state-BLIND_SELECT--blinds.big.status-SELECT": [ { - "endpoint": "menu", - "arguments": {} + "method": "menu", + "params": {} }, { - "endpoint": "start", - "arguments": { + "method": "start", + "params": { "deck": "RED", "stake": "WHITE", "seed": "TEST123" } }, { - "endpoint": "skip", - "arguments": {} + "method": "skip", + "params": {} } ], "state-BLIND_SELECT--blinds.boss.status-SELECT": [ { - "endpoint": "menu", - "arguments": {} + "method": "menu", + "params": {} }, { - "endpoint": "start", - "arguments": { + "method": "start", + "params": { "deck": "RED", "stake": "WHITE", "seed": "TEST123" } }, { - "endpoint": "skip", - "arguments": {} + "method": "skip", + "params": {} }, { - "endpoint": "skip", - "arguments": {} + "method": "skip", + "params": {} } ] }, "play": { "state-BLIND_SELECT": [ { - "endpoint": "menu", - "arguments": {} + "method": "menu", + "params": {} }, { - "endpoint": "start", - "arguments": { + "method": "start", + "params": { "deck": "RED", "stake": "WHITE", "seed": "TEST123" @@ -281,42 +281,42 @@ ], "state-SELECTING_HAND": [ { - "endpoint": "menu", - "arguments": {} + "method": "menu", + "params": {} }, { - "endpoint": "start", - "arguments": { + "method": "start", + "params": { "deck": "RED", "stake": "WHITE", "seed": "TEST123" } }, { - "endpoint": "select", - "arguments": {} + "method": "select", + "params": {} } ], "state-SELECTING_HAND--round.chips-200": [ { - "endpoint": "menu", - "arguments": {} + "method": "menu", + "params": {} }, { - "endpoint": "start", - "arguments": { + "method": "start", + "params": { "deck": "RED", "stake": "WHITE", "seed": "TEST123" } }, { - "endpoint": "select", - "arguments": {} + "method": "select", + "params": {} }, { - "endpoint": "set", - "arguments": { + "method": "set", + "params": { "chips": 200, "hands": 1, "discards": 0 @@ -325,56 +325,56 @@ ], "state-SELECTING_HAND--round.hands_left-1": [ { - "endpoint": "menu", - "arguments": {} + "method": "menu", + "params": {} }, { - "endpoint": "start", - "arguments": { + "method": "start", + "params": { "deck": "RED", "stake": "WHITE", "seed": "TEST123" } }, { - "endpoint": "select", - "arguments": {} + "method": "select", + "params": {} }, { - "endpoint": "set", - "arguments": { + "method": "set", + "params": { "hands": 1 } } ], "state-SELECTING_HAND--ante_num-8--blinds.boss.status-CURRENT--round.chips-1000000": [ { - "endpoint": "menu", - "arguments": {} + "method": "menu", + "params": {} }, { - "endpoint": "start", - "arguments": { + "method": "start", + "params": { "deck": "RED", "stake": "WHITE", "seed": "TEST123" } }, { - "endpoint": "skip", - "arguments": {} + "method": "skip", + "params": {} }, { - "endpoint": "skip", - "arguments": {} + "method": "skip", + "params": {} }, { - "endpoint": "select", - "arguments": {} + "method": "select", + "params": {} }, { - "endpoint": "set", - "arguments": { + "method": "set", + "params": { "ante": 8, "chips": 1000000 } @@ -384,12 +384,12 @@ "discard": { "state-BLIND_SELECT": [ { - "endpoint": "menu", - "arguments": {} + "method": "menu", + "params": {} }, { - "endpoint": "start", - "arguments": { + "method": "start", + "params": { "deck": "RED", "stake": "WHITE", "seed": "TEST123" @@ -398,42 +398,42 @@ ], "state-SELECTING_HAND": [ { - "endpoint": "menu", - "arguments": {} + "method": "menu", + "params": {} }, { - "endpoint": "start", - "arguments": { + "method": "start", + "params": { "deck": "RED", "stake": "WHITE", "seed": "TEST123" } }, { - "endpoint": "select", - "arguments": {} + "method": "select", + "params": {} } ], "state-SELECTING_HAND--round.discards_left-0": [ { - "endpoint": "menu", - "arguments": {} + "method": "menu", + "params": {} }, { - "endpoint": "start", - "arguments": { + "method": "start", + "params": { "deck": "RED", "stake": "WHITE", "seed": "TEST123" } }, { - "endpoint": "select", - "arguments": {} + "method": "select", + "params": {} }, { - "endpoint": "set", - "arguments": { + "method": "set", + "params": { "discards": 0 } } @@ -442,12 +442,12 @@ "cash_out": { "state-BLIND_SELECT": [ { - "endpoint": "menu", - "arguments": {} + "method": "menu", + "params": {} }, { - "endpoint": "start", - "arguments": { + "method": "start", + "params": { "deck": "RED", "stake": "WHITE", "seed": "TEST123" @@ -456,30 +456,30 @@ ], "state-ROUND_EVAL": [ { - "endpoint": "menu", - "arguments": {} + "method": "menu", + "params": {} }, { - "endpoint": "start", - "arguments": { + "method": "start", + "params": { "deck": "RED", "stake": "WHITE", "seed": "TEST123" } }, { - "endpoint": "select", - "arguments": {} + "method": "select", + "params": {} }, { - "endpoint": "set", - "arguments": { + "method": "set", + "params": { "chips": 1000 } }, { - "endpoint": "play", - "arguments": { + "method": "play", + "params": { "cards": [ 0 ] @@ -490,12 +490,12 @@ "next_round": { "state-BLIND_SELECT": [ { - "endpoint": "menu", - "arguments": {} + "method": "menu", + "params": {} }, { - "endpoint": "start", - "arguments": { + "method": "start", + "params": { "deck": "RED", "stake": "WHITE", "seed": "TEST123" @@ -504,50 +504,50 @@ ], "state-SHOP": [ { - "endpoint": "menu", - "arguments": {} + "method": "menu", + "params": {} }, { - "endpoint": "start", - "arguments": { + "method": "start", + "params": { "deck": "RED", "stake": "WHITE", "seed": "TEST123" } }, { - "endpoint": "select", - "arguments": {} + "method": "select", + "params": {} }, { - "endpoint": "set", - "arguments": { + "method": "set", + "params": { "chips": 1000 } }, { - "endpoint": "play", - "arguments": { + "method": "play", + "params": { "cards": [ 0 ] } }, { - "endpoint": "cash_out", - "arguments": {} + "method": "cash_out", + "params": {} } ] }, "reroll": { "state-BLIND_SELECT": [ { - "endpoint": "menu", - "arguments": {} + "method": "menu", + "params": {} }, { - "endpoint": "start", - "arguments": { + "method": "start", + "params": { "deck": "RED", "stake": "WHITE", "seed": "TEST123" @@ -556,78 +556,78 @@ ], "state-SHOP": [ { - "endpoint": "menu", - "arguments": {} + "method": "menu", + "params": {} }, { - "endpoint": "start", - "arguments": { + "method": "start", + "params": { "deck": "RED", "stake": "WHITE", "seed": "TEST123" } }, { - "endpoint": "select", - "arguments": {} + "method": "select", + "params": {} }, { - "endpoint": "set", - "arguments": { + "method": "set", + "params": { "chips": 1000 } }, { - "endpoint": "play", - "arguments": { + "method": "play", + "params": { "cards": [ 0 ] } }, { - "endpoint": "cash_out", - "arguments": {} + "method": "cash_out", + "params": {} } ], "state-SHOP--money-0": [ { - "endpoint": "menu", - "arguments": {} + "method": "menu", + "params": {} }, { - "endpoint": "start", - "arguments": { + "method": "start", + "params": { "deck": "RED", "stake": "WHITE", "seed": "TEST123" } }, { - "endpoint": "select", - "arguments": {} + "method": "select", + "params": {} }, { - "endpoint": "set", - "arguments": { + "method": "set", + "params": { "chips": 1000 } }, { - "endpoint": "play", - "arguments": { + "method": "play", + "params": { "cards": [ 0 ] } }, { - "endpoint": "cash_out", - "arguments": {} + "method": "cash_out", + "params": {} }, { - "endpoint": "set", - "arguments": { + "method": "set", + "params": { "money": 0 } } @@ -636,12 +636,12 @@ "buy": { "state-BLIND_SELECT": [ { - "endpoint": "menu", - "arguments": {} + "method": "menu", + "params": {} }, { - "endpoint": "start", - "arguments": { + "method": "start", + "params": { "deck": "RED", "stake": "WHITE", "seed": "TEST123" @@ -650,460 +650,460 @@ ], "state-SHOP--shop.cards[0].set-JOKER": [ { - "endpoint": "menu", - "arguments": {} + "method": "menu", + "params": {} }, { - "endpoint": "start", - "arguments": { + "method": "start", + "params": { "deck": "RED", "stake": "WHITE", "seed": "TEST123" } }, { - "endpoint": "select", - "arguments": {} + "method": "select", + "params": {} }, { - "endpoint": "set", - "arguments": { + "method": "set", + "params": { "chips": 1000 } }, { - "endpoint": "play", - "arguments": { + "method": "play", + "params": { "cards": [ 0 ] } }, { - "endpoint": "cash_out", - "arguments": {} + "method": "cash_out", + "params": {} } ], "state-SHOP--shop.cards[1].set-PLANET": [ { - "endpoint": "menu", - "arguments": {} + "method": "menu", + "params": {} }, { - "endpoint": "start", - "arguments": { + "method": "start", + "params": { "deck": "RED", "stake": "WHITE", "seed": "TEST123" } }, { - "endpoint": "select", - "arguments": {} + "method": "select", + "params": {} }, { - "endpoint": "set", - "arguments": { + "method": "set", + "params": { "chips": 1000 } }, { - "endpoint": "play", - "arguments": { + "method": "play", + "params": { "cards": [ 0 ] } }, { - "endpoint": "cash_out", - "arguments": {} + "method": "cash_out", + "params": {} } ], "state-SHOP--voucher.cards[0].set-VOUCHER": [ { - "endpoint": "menu", - "arguments": {} + "method": "menu", + "params": {} }, { - "endpoint": "start", - "arguments": { + "method": "start", + "params": { "deck": "RED", "stake": "WHITE", "seed": "TEST123" } }, { - "endpoint": "select", - "arguments": {} + "method": "select", + "params": {} }, { - "endpoint": "set", - "arguments": { + "method": "set", + "params": { "chips": 1000 } }, { - "endpoint": "play", - "arguments": { + "method": "play", + "params": { "cards": [ 0 ] } }, { - "endpoint": "cash_out", - "arguments": {} + "method": "cash_out", + "params": {} } ], "state-SHOP--packs.cards[0].label-Buffoon+Pack--packs.cards[1].label-Standard+Pack": [ { - "endpoint": "menu", - "arguments": {} + "method": "menu", + "params": {} }, { - "endpoint": "start", - "arguments": { + "method": "start", + "params": { "deck": "RED", "stake": "WHITE", "seed": "TEST123" } }, { - "endpoint": "select", - "arguments": {} + "method": "select", + "params": {} }, { - "endpoint": "set", - "arguments": { + "method": "set", + "params": { "chips": 1000 } }, { - "endpoint": "play", - "arguments": { + "method": "play", + "params": { "cards": [ 0 ] } }, { - "endpoint": "cash_out", - "arguments": {} + "method": "cash_out", + "params": {} } ], "state-SHOP--voucher.count-0": [ { - "endpoint": "menu", - "arguments": {} + "method": "menu", + "params": {} }, { - "endpoint": "start", - "arguments": { + "method": "start", + "params": { "deck": "RED", "stake": "WHITE", "seed": "TEST123" } }, { - "endpoint": "select", - "arguments": {} + "method": "select", + "params": {} }, { - "endpoint": "set", - "arguments": { + "method": "set", + "params": { "chips": 1000 } }, { - "endpoint": "play", - "arguments": { + "method": "play", + "params": { "cards": [ 0 ] } }, { - "endpoint": "cash_out", - "arguments": {} + "method": "cash_out", + "params": {} }, { - "endpoint": "buy", - "arguments": { + "method": "buy", + "params": { "voucher": 0 } } ], "state-SHOP--shop.cards[1].set-TAROT": [ { - "endpoint": "menu", - "arguments": {} + "method": "menu", + "params": {} }, { - "endpoint": "start", - "arguments": { + "method": "start", + "params": { "deck": "RED", "stake": "WHITE", "seed": "TEST123" } }, { - "endpoint": "select", - "arguments": {} + "method": "select", + "params": {} }, { - "endpoint": "set", - "arguments": { + "method": "set", + "params": { "chips": 1000 } }, { - "endpoint": "play", - "arguments": { + "method": "play", + "params": { "cards": [ 0 ] } }, { - "endpoint": "cash_out", - "arguments": {} + "method": "cash_out", + "params": {} }, { - "endpoint": "reroll", - "arguments": {} + "method": "reroll", + "params": {} } ], "state-SHOP--shop.count-0": [ { - "endpoint": "menu", - "arguments": {} + "method": "menu", + "params": {} }, { - "endpoint": "start", - "arguments": { + "method": "start", + "params": { "deck": "RED", "stake": "WHITE", "seed": "TEST123" } }, { - "endpoint": "select", - "arguments": {} + "method": "select", + "params": {} }, { - "endpoint": "set", - "arguments": { + "method": "set", + "params": { "chips": 1000 } }, { - "endpoint": "play", - "arguments": { + "method": "play", + "params": { "cards": [ 0 ] } }, { - "endpoint": "cash_out", - "arguments": {} + "method": "cash_out", + "params": {} }, { - "endpoint": "buy", - "arguments": { + "method": "buy", + "params": { "card": 0 } }, { - "endpoint": "buy", - "arguments": { + "method": "buy", + "params": { "card": 0 } } ], "state-SHOP--jokers.count-5--shop.cards[0].set-JOKER": [ { - "endpoint": "menu", - "arguments": {} + "method": "menu", + "params": {} }, { - "endpoint": "start", - "arguments": { + "method": "start", + "params": { "deck": "RED", "stake": "WHITE", "seed": "TEST123" } }, { - "endpoint": "select", - "arguments": {} + "method": "select", + "params": {} }, { - "endpoint": "set", - "arguments": { + "method": "set", + "params": { "chips": 1000, "money": 1000 } }, { - "endpoint": "play", - "arguments": { + "method": "play", + "params": { "cards": [ 0 ] } }, { - "endpoint": "cash_out", - "arguments": {} + "method": "cash_out", + "params": {} }, { - "endpoint": "buy", - "arguments": { + "method": "buy", + "params": { "card": 0 } }, { - "endpoint": "reroll", - "arguments": {} + "method": "reroll", + "params": {} }, { - "endpoint": "buy", - "arguments": { + "method": "buy", + "params": { "card": 0 } }, { - "endpoint": "reroll", - "arguments": {} + "method": "reroll", + "params": {} }, { - "endpoint": "buy", - "arguments": { + "method": "buy", + "params": { "card": 0 } }, { - "endpoint": "buy", - "arguments": { + "method": "buy", + "params": { "card": 0 } }, { - "endpoint": "reroll", - "arguments": {} + "method": "reroll", + "params": {} }, { - "endpoint": "buy", - "arguments": { + "method": "buy", + "params": { "card": 0 } } ], "state-SHOP--consumables.count-2--shop.cards[1].set-PLANET": [ { - "endpoint": "menu", - "arguments": {} + "method": "menu", + "params": {} }, { - "endpoint": "start", - "arguments": { + "method": "start", + "params": { "deck": "RED", "stake": "WHITE", "seed": "TEST123" } }, { - "endpoint": "select", - "arguments": {} + "method": "select", + "params": {} }, { - "endpoint": "set", - "arguments": { + "method": "set", + "params": { "chips": 1000, "money": 1000 } }, { - "endpoint": "play", - "arguments": { + "method": "play", + "params": { "cards": [ 0 ] } }, { - "endpoint": "cash_out", - "arguments": {} + "method": "cash_out", + "params": {} }, { - "endpoint": "buy", - "arguments": { + "method": "buy", + "params": { "card": 1 } }, { - "endpoint": "reroll", - "arguments": {} + "method": "reroll", + "params": {} }, { - "endpoint": "buy", - "arguments": { + "method": "buy", + "params": { "card": 1 } }, { - "endpoint": "reroll", - "arguments": {} + "method": "reroll", + "params": {} }, { - "endpoint": "reroll", - "arguments": {} + "method": "reroll", + "params": {} }, { - "endpoint": "reroll", - "arguments": {} + "method": "reroll", + "params": {} } ], "state-SHOP--money-0": [ { - "endpoint": "menu", - "arguments": {} + "method": "menu", + "params": {} }, { - "endpoint": "start", - "arguments": { + "method": "start", + "params": { "deck": "RED", "stake": "WHITE", "seed": "TEST123" } }, { - "endpoint": "select", - "arguments": {} + "method": "select", + "params": {} }, { - "endpoint": "set", - "arguments": { + "method": "set", + "params": { "chips": 1000 } }, { - "endpoint": "play", - "arguments": { + "method": "play", + "params": { "cards": [ 0 ] } }, { - "endpoint": "cash_out", - "arguments": {} + "method": "cash_out", + "params": {} }, { - "endpoint": "set", - "arguments": { + "method": "set", + "params": { "money": 0 } } @@ -1112,12 +1112,12 @@ "rearrange": { "state-BLIND_SELECT": [ { - "endpoint": "menu", - "arguments": {} + "method": "menu", + "params": {} }, { - "endpoint": "start", - "arguments": { + "method": "start", + "params": { "deck": "RED", "stake": "WHITE", "seed": "TEST123" @@ -1126,99 +1126,99 @@ ], "state-SELECTING_HAND--hand.count-8": [ { - "endpoint": "menu", - "arguments": {} + "method": "menu", + "params": {} }, { - "endpoint": "start", - "arguments": { + "method": "start", + "params": { "deck": "RED", "stake": "WHITE", "seed": "TEST123" } }, { - "endpoint": "select", - "arguments": {} + "method": "select", + "params": {} } ], "state-SHOP--jokers.count-4--consumables.count-2": [ { - "endpoint": "menu", - "arguments": {} + "method": "menu", + "params": {} }, { - "endpoint": "start", - "arguments": { + "method": "start", + "params": { "deck": "RED", "stake": "WHITE", "seed": "TEST123" } }, { - "endpoint": "select", - "arguments": {} + "method": "select", + "params": {} }, { - "endpoint": "set", - "arguments": { + "method": "set", + "params": { "chips": 1000, "money": 1000 } }, { - "endpoint": "play", - "arguments": { + "method": "play", + "params": { "cards": [ 0 ] } }, { - "endpoint": "cash_out", - "arguments": {} + "method": "cash_out", + "params": {} }, { - "endpoint": "buy", - "arguments": { + "method": "buy", + "params": { "card": 0 } }, { - "endpoint": "buy", - "arguments": { + "method": "buy", + "params": { "card": 0 } }, { - "endpoint": "reroll", - "arguments": {} + "method": "reroll", + "params": {} }, { - "endpoint": "buy", - "arguments": { + "method": "buy", + "params": { "card": 0 } }, { - "endpoint": "buy", - "arguments": { + "method": "buy", + "params": { "card": 0 } }, { - "endpoint": "reroll", - "arguments": {} + "method": "reroll", + "params": {} }, { - "endpoint": "buy", - "arguments": { + "method": "buy", + "params": { "card": 0 } }, { - "endpoint": "buy", - "arguments": { + "method": "buy", + "params": { "card": 0 } } @@ -1227,12 +1227,12 @@ "sell": { "state-BLIND_SELECT": [ { - "endpoint": "menu", - "arguments": {} + "method": "menu", + "params": {} }, { - "endpoint": "start", - "arguments": { + "method": "start", + "params": { "deck": "RED", "stake": "WHITE", "seed": "TEST123" @@ -1241,30 +1241,30 @@ ], "state-ROUND_EVAL": [ { - "endpoint": "menu", - "arguments": {} + "method": "menu", + "params": {} }, { - "endpoint": "start", - "arguments": { + "method": "start", + "params": { "deck": "RED", "stake": "WHITE", "seed": "TEST123" } }, { - "endpoint": "select", - "arguments": {} + "method": "select", + "params": {} }, { - "endpoint": "set", - "arguments": { + "method": "set", + "params": { "chips": 1000 } }, { - "endpoint": "play", - "arguments": { + "method": "play", + "params": { "cards": [ 0 ] @@ -1273,138 +1273,138 @@ ], "state-SELECTING_HAND--jokers.count-0--consumables.count-0": [ { - "endpoint": "menu", - "arguments": {} + "method": "menu", + "params": {} }, { - "endpoint": "start", - "arguments": { + "method": "start", + "params": { "deck": "RED", "stake": "WHITE", "seed": "TEST123" } }, { - "endpoint": "select", - "arguments": {} + "method": "select", + "params": {} } ], "state-SHOP--jokers.count-1--consumables.count-1": [ { - "endpoint": "menu", - "arguments": {} + "method": "menu", + "params": {} }, { - "endpoint": "start", - "arguments": { + "method": "start", + "params": { "deck": "RED", "stake": "WHITE", "seed": "TEST123" } }, { - "endpoint": "select", - "arguments": {} + "method": "select", + "params": {} }, { - "endpoint": "set", - "arguments": { + "method": "set", + "params": { "chips": 1000, "money": 1000 } }, { - "endpoint": "play", - "arguments": { + "method": "play", + "params": { "cards": [ 0 ] } }, { - "endpoint": "cash_out", - "arguments": {} + "method": "cash_out", + "params": {} }, { - "endpoint": "buy", - "arguments": { + "method": "buy", + "params": { "card": 0 } }, { - "endpoint": "buy", - "arguments": { + "method": "buy", + "params": { "card": 0 } } ], "state-SELECTING_HAND--jokers.count-1--consumables.count-1": [ { - "endpoint": "menu", - "arguments": {} + "method": "menu", + "params": {} }, { - "endpoint": "start", - "arguments": { + "method": "start", + "params": { "deck": "RED", "stake": "WHITE", "seed": "TEST123" } }, { - "endpoint": "select", - "arguments": {} + "method": "select", + "params": {} }, { - "endpoint": "set", - "arguments": { + "method": "set", + "params": { "chips": 1000, "money": 1000 } }, { - "endpoint": "play", - "arguments": { + "method": "play", + "params": { "cards": [ 0 ] } }, { - "endpoint": "cash_out", - "arguments": {} + "method": "cash_out", + "params": {} }, { - "endpoint": "buy", - "arguments": { + "method": "buy", + "params": { "card": 0 } }, { - "endpoint": "buy", - "arguments": { + "method": "buy", + "params": { "card": 0 } }, { - "endpoint": "next_round", - "arguments": {} + "method": "next_round", + "params": {} }, { - "endpoint": "select", - "arguments": {} + "method": "select", + "params": {} } ] }, "add": { "state-BLIND_SELECT": [ { - "endpoint": "menu", - "arguments": {} + "method": "menu", + "params": {} }, { - "endpoint": "start", - "arguments": { + "method": "start", + "params": { "deck": "RED", "stake": "WHITE", "seed": "TEST123" @@ -1413,60 +1413,60 @@ ], "state-SELECTING_HAND--jokers.count-0--consumables.count-0--hand.count-8": [ { - "endpoint": "menu", - "arguments": {} + "method": "menu", + "params": {} }, { - "endpoint": "start", - "arguments": { + "method": "start", + "params": { "deck": "RED", "stake": "WHITE", "seed": "TEST123" } }, { - "endpoint": "select", - "arguments": {} + "method": "select", + "params": {} } ], "state-SHOP--jokers.count-0--consumables.count-0--vouchers.count-0": [ { - "endpoint": "menu", - "arguments": {} + "method": "menu", + "params": {} }, { - "endpoint": "start", - "arguments": { + "method": "start", + "params": { "deck": "RED", "stake": "WHITE", "seed": "TEST123" } }, { - "endpoint": "select", - "arguments": {} + "method": "select", + "params": {} }, { - "endpoint": "set", - "arguments": { + "method": "set", + "params": { "chips": 1000 } }, { - "endpoint": "play", - "arguments": { + "method": "play", + "params": { "cards": [ 0 ] } }, { - "endpoint": "cash_out", - "arguments": {} + "method": "cash_out", + "params": {} }, { - "endpoint": "buy", - "arguments": { + "method": "buy", + "params": { "voucher": 0 } } @@ -1475,12 +1475,12 @@ "use": { "state-BLIND_SELECT": [ { - "endpoint": "menu", - "arguments": {} + "method": "menu", + "params": {} }, { - "endpoint": "start", - "arguments": { + "method": "start", + "params": { "deck": "RED", "stake": "WHITE", "seed": "TEST123" @@ -1489,31 +1489,31 @@ ], "state-ROUND_EVAL": [ { - "endpoint": "menu", - "arguments": {} + "method": "menu", + "params": {} }, { - "endpoint": "start", - "arguments": { + "method": "start", + "params": { "deck": "RED", "stake": "WHITE", "seed": "TEST123" } }, { - "endpoint": "select", - "arguments": {} + "method": "select", + "params": {} }, { - "endpoint": "set", - "arguments": { + "method": "set", + "params": { "chips": 1000, "money": 1000 } }, { - "endpoint": "play", - "arguments": { + "method": "play", + "params": { "cards": [ 0 ] @@ -1522,339 +1522,339 @@ ], "state-SELECTING_HAND--money-12--consumables.cards[0]-key-c_hermit": [ { - "endpoint": "menu", - "arguments": {} + "method": "menu", + "params": {} }, { - "endpoint": "start", - "arguments": { + "method": "start", + "params": { "deck": "RED", "stake": "WHITE", "seed": "TEST123" } }, { - "endpoint": "select", - "arguments": {} + "method": "select", + "params": {} }, { - "endpoint": "set", - "arguments": { + "method": "set", + "params": { "money": 12 } }, { - "endpoint": "add", - "arguments": { + "method": "add", + "params": { "key": "c_hermit" } } ], "state-SELECTING_HAND--consumables.cards[0]-key-c_familiar": [ { - "endpoint": "menu", - "arguments": {} + "method": "menu", + "params": {} }, { - "endpoint": "start", - "arguments": { + "method": "start", + "params": { "deck": "RED", "stake": "WHITE", "seed": "TEST123" } }, { - "endpoint": "select", - "arguments": {} + "method": "select", + "params": {} }, { - "endpoint": "add", - "arguments": { + "method": "add", + "params": { "key": "c_familiar" } } ], "state-SHOP--consumables.cards[0]-key-c_familiar": [ { - "endpoint": "menu", - "arguments": {} + "method": "menu", + "params": {} }, { - "endpoint": "start", - "arguments": { + "method": "start", + "params": { "deck": "RED", "stake": "WHITE", "seed": "TEST123" } }, { - "endpoint": "select", - "arguments": {} + "method": "select", + "params": {} }, { - "endpoint": "set", - "arguments": { + "method": "set", + "params": { "chips": 1000 } }, { - "endpoint": "play", - "arguments": { + "method": "play", + "params": { "cards": [ 0 ] } }, { - "endpoint": "cash_out", - "arguments": {} + "method": "cash_out", + "params": {} }, { - "endpoint": "add", - "arguments": { + "method": "add", + "params": { "key": "c_familiar" } } ], "state-SELECTING_HAND--consumables.cards[0]-key-c_temperance--jokers.count-0": [ { - "endpoint": "menu", - "arguments": {} + "method": "menu", + "params": {} }, { - "endpoint": "start", - "arguments": { + "method": "start", + "params": { "deck": "RED", "stake": "WHITE", "seed": "TEST123" } }, { - "endpoint": "select", - "arguments": {} + "method": "select", + "params": {} }, { - "endpoint": "add", - "arguments": { + "method": "add", + "params": { "key": "c_temperance" } } ], "state-SHOP--money-12--consumables.cards[0]-key-c_hermit": [ { - "endpoint": "menu", - "arguments": {} + "method": "menu", + "params": {} }, { - "endpoint": "start", - "arguments": { + "method": "start", + "params": { "deck": "RED", "stake": "WHITE", "seed": "TEST123" } }, { - "endpoint": "select", - "arguments": {} + "method": "select", + "params": {} }, { - "endpoint": "set", - "arguments": { + "method": "set", + "params": { "chips": 1000 } }, { - "endpoint": "play", - "arguments": { + "method": "play", + "params": { "cards": [ 0 ] } }, { - "endpoint": "cash_out", - "arguments": {} + "method": "cash_out", + "params": {} }, { - "endpoint": "set", - "arguments": { + "method": "set", + "params": { "money": 12 } }, { - "endpoint": "add", - "arguments": { + "method": "add", + "params": { "key": "c_hermit" } } ], "state-SELECTING_HAND--consumables.cards[0].key-c_pluto--consumables.cards[1].key-c_magician": [ { - "endpoint": "menu", - "arguments": {} + "method": "menu", + "params": {} }, { - "endpoint": "start", - "arguments": { + "method": "start", + "params": { "deck": "RED", "stake": "WHITE", "seed": "TEST123" } }, { - "endpoint": "select", - "arguments": {} + "method": "select", + "params": {} }, { - "endpoint": "set", - "arguments": { + "method": "set", + "params": { "chips": 1000, "money": 1000 } }, { - "endpoint": "play", - "arguments": { + "method": "play", + "params": { "cards": [ 0 ] } }, { - "endpoint": "cash_out", - "arguments": {} + "method": "cash_out", + "params": {} }, { - "endpoint": "buy", - "arguments": { + "method": "buy", + "params": { "card": 1 } }, { - "endpoint": "reroll", - "arguments": {} + "method": "reroll", + "params": {} }, { - "endpoint": "buy", - "arguments": { + "method": "buy", + "params": { "card": 1 } }, { - "endpoint": "next_round", - "arguments": {} + "method": "next_round", + "params": {} }, { - "endpoint": "select", - "arguments": {} + "method": "select", + "params": {} } ], "state-SELECTING_HAND--consumables.cards[0].key-c_death": [ { - "endpoint": "menu", - "arguments": {} + "method": "menu", + "params": {} }, { - "endpoint": "start", - "arguments": { + "method": "start", + "params": { "deck": "RED", "stake": "WHITE", "seed": "TEST123" } }, { - "endpoint": "select", - "arguments": {} + "method": "select", + "params": {} }, { - "endpoint": "add", - "arguments": { + "method": "add", + "params": { "key": "c_death" } } ], "state-SHOP--consumables.cards[0].key-c_magician": [ { - "endpoint": "menu", - "arguments": {} + "method": "menu", + "params": {} }, { - "endpoint": "start", - "arguments": { + "method": "start", + "params": { "deck": "RED", "stake": "WHITE", "seed": "TEST123" } }, { - "endpoint": "select", - "arguments": {} + "method": "select", + "params": {} }, { - "endpoint": "set", - "arguments": { + "method": "set", + "params": { "chips": 1000, "money": 1000 } }, { - "endpoint": "play", - "arguments": { + "method": "play", + "params": { "cards": [ 0 ] } }, { - "endpoint": "cash_out", - "arguments": {} + "method": "cash_out", + "params": {} }, { - "endpoint": "add", - "arguments": { + "method": "add", + "params": { "key": "c_magician" } } ], "state-SHOP--consumables.cards[0].key-c_strength": [ { - "endpoint": "menu", - "arguments": {} + "method": "menu", + "params": {} }, { - "endpoint": "start", - "arguments": { + "method": "start", + "params": { "deck": "RED", "stake": "WHITE", "seed": "TEST123" } }, { - "endpoint": "select", - "arguments": {} + "method": "select", + "params": {} }, { - "endpoint": "set", - "arguments": { + "method": "set", + "params": { "chips": 1000, "money": 1000 } }, { - "endpoint": "play", - "arguments": { + "method": "play", + "params": { "cards": [ 0 ] } }, { - "endpoint": "cash_out", - "arguments": {} + "method": "cash_out", + "params": {} }, { - "endpoint": "add", - "arguments": { + "method": "add", + "params": { "key": "c_strength" } } @@ -1863,12 +1863,12 @@ "screenshot": { "state-BLIND_SELECT": [ { - "endpoint": "menu", - "arguments": {} + "method": "menu", + "params": {} }, { - "endpoint": "start", - "arguments": { + "method": "start", + "params": { "deck": "RED", "stake": "WHITE", "seed": "TEST123" diff --git a/tests/fixtures/fixtures.schema.json b/tests/fixtures/fixtures.schema.json deleted file mode 100644 index d6d4152..0000000 --- a/tests/fixtures/fixtures.schema.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "Balatro API Test Fixtures Schema", - "type": "object", - "properties": { - "$schema": { - "type": "string", - "description": "JSON Schema reference" - } - }, - "additionalProperties": { - "type": "object", - "additionalProperties": { - "type": "array", - "items": { - "type": "object", - "required": ["endpoint", "arguments"], - "properties": { - "endpoint": { - "type": "string" - }, - "arguments": { - "type": "object" - } - } - } - } - } -} From e02ae34f023846a2a5cfa69373ae1068a544ffce Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Thu, 11 Dec 2025 17:14:59 +0100 Subject: [PATCH 228/230] fix: update makefile to use the correct path for basedpyright --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 3bcb41a..5eb6f08 100644 --- a/Makefile +++ b/Makefile @@ -47,7 +47,7 @@ format: ## Run ruff and mdformat formatters typecheck: ## Run type checker @echo "$(YELLOW)Running type checker...$(RESET)" - basedpyright src/balatrobot + basedpyright tests/ quality: lint typecheck format ## Run all code quality checks @echo "$(GREEN)✓ All checks completed$(RESET)" From 59ea3f12aec18152b214060fde715f8fae4c642d Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Thu, 11 Dec 2025 17:15:24 +0100 Subject: [PATCH 229/230] refactor(fixtures): update the test scripts to use the new fixture keys --- tests/fixtures/generate.py | 10 +++++----- tests/lua/conftest.py | 8 ++++---- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/tests/fixtures/generate.py b/tests/fixtures/generate.py index 79abdf2..27b3bcd 100644 --- a/tests/fixtures/generate.py +++ b/tests/fixtures/generate.py @@ -20,8 +20,8 @@ class FixtureSpec: setup: list[tuple[str, dict]] -def api(sock: socket.socket, name: str, arguments: dict) -> dict: - request = {"name": name, "arguments": arguments} +def api(sock: socket.socket, method: str, params: dict) -> dict: + request = {"method": method, "params": params} message = json.dumps(request) + "\n" sock.sendall(message.encode()) @@ -46,7 +46,7 @@ def load_fixtures_json() -> dict: def steps_to_setup(steps: list[dict]) -> list[tuple[str, dict]]: - return [(step["endpoint"], step["arguments"]) for step in steps] + return [(step["method"], step["params"]) for step in steps] def steps_to_key(steps: list[dict]) -> str: @@ -82,8 +82,8 @@ def generate_fixture(sock: socket.socket, spec: FixtureSpec, pbar: tqdm) -> bool relative_path = primary_path.relative_to(FIXTURES_DIR) try: - for endpoint, arguments in spec.setup: - response = api(sock, endpoint, arguments) + for method, params in spec.setup: + response = api(sock, method, params) if "error" in response: pbar.write(f" Error: {relative_path} - {response['error']}") return False diff --git a/tests/lua/conftest.py b/tests/lua/conftest.py index 0edc3e1..98416f3 100644 --- a/tests/lua/conftest.py +++ b/tests/lua/conftest.py @@ -182,15 +182,15 @@ def load_fixture( # Execute each setup step for step in setup_steps: - step_endpoint = step["endpoint"] - step_arguments = step.get("arguments", {}) - response = api(client, step_endpoint, step_arguments) + 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_endpoint}: {error_msg}" + f"Fixture generation failed at step {step_method}: {error_msg}" ) # Save the fixture From abc229312686bfa33905e373fdae398bf00c664d Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Thu, 11 Dec 2025 17:47:05 +0100 Subject: [PATCH 230/230] feat(fixtures): update generate.py to use httpx --- tests/fixtures/generate.py | 70 ++++++++++++++++++++++---------------- 1 file changed, 41 insertions(+), 29 deletions(-) diff --git a/tests/fixtures/generate.py b/tests/fixtures/generate.py index 27b3bcd..773722f 100644 --- a/tests/fixtures/generate.py +++ b/tests/fixtures/generate.py @@ -1,17 +1,19 @@ #!/usr/bin/env python3 import json -import socket 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 -BUFFER_SIZE = 65536 + +# JSON-RPC 2.0 request ID counter +_request_id: int = 0 @dataclass @@ -20,19 +22,27 @@ class FixtureSpec: setup: list[tuple[str, dict]] -def api(sock: socket.socket, method: str, params: dict) -> dict: - request = {"method": method, "params": params} - message = json.dumps(request) + "\n" - sock.sendall(message.encode()) +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() - response = sock.recv(BUFFER_SIZE) - decoded = response.decode() - first_newline = decoded.find("\n") - if first_newline != -1: - first_message = decoded[:first_newline] - else: - first_message = decoded.strip() - return json.loads(first_message) + # 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: @@ -77,21 +87,23 @@ def aggregate_fixtures(json_data: dict) -> list[FixtureSpec]: return fixtures -def generate_fixture(sock: socket.socket, spec: FixtureSpec, pbar: tqdm) -> bool: +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(sock, method, params) - if "error" in response: - pbar.write(f" Error: {relative_path} - {response['error']}") + 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(sock, "save", {"path": str(primary_path)}) - if "error" in response: - pbar.write(f" Error: {relative_path} - {response['error']}") + 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:]: @@ -114,10 +126,10 @@ def main() -> int: print(f"Loaded {len(fixtures)} unique fixture configurations\n") try: - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: - sock.connect((HOST, PORT)) - sock.settimeout(10) - + with httpx.Client( + base_url=f"http://{HOST}:{PORT}", + timeout=httpx.Timeout(60.0, read=10.0), + ) as client: success = 0 failed = 0 @@ -125,13 +137,13 @@ def main() -> int: total=len(fixtures), desc="Generating fixtures", unit="fixture" ) as pbar: for spec in fixtures: - if generate_fixture(sock, spec, pbar): + if generate_fixture(client, spec, pbar): success += 1 else: failed += 1 pbar.update(1) - api(sock, "menu", {}) + api(client, "menu", {}) corrupted_path = FIXTURES_DIR / "load" / "corrupted.jkr" corrupt_file(corrupted_path) @@ -140,11 +152,11 @@ def main() -> int: print(f"\nSummary: {success} generated, {failed} failed") return 1 if failed > 0 else 0 - except ConnectionRefusedError: + 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 socket.timeout: + except httpx.TimeoutException: print(f"Error: Connection timeout to Balatro at {HOST}:{PORT}") return 1 except Exception as e: