Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .claude/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,11 @@
"Bash(ruff format:*)",
"Bash(basedpyright:*)"
],
"deny": []
"deny": [
"Edit(CHANGELOG.md)",
"MultiEdit(CHANGELOG.md)",
"Write(CHANGELOG.md)"
]
},
"hooks": {
"PostToolUse": [
Expand Down
1 change: 0 additions & 1 deletion docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,6 @@ BalatroBot is a Python framework designed to help developers create automated bo

[:octicons-arrow-right-24: Protocol API](protocol-api.md)


- :octicons-sparkle-fill-16:{ .lg .middle } __Documentation for LLM__

---
Expand Down
119 changes: 119 additions & 0 deletions docs/logging-systems.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
# 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 streamodded 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
- `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/S1M0N38/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")
```
2 changes: 1 addition & 1 deletion mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,6 @@ plugins:
The project enables real-time bidirectional communication between the game and bot through TCP sockets.
sections:
Documentation:
- index.md
- installation.md
- developing-bots.md
- balatrobot-api.md
Expand All @@ -62,6 +61,7 @@ nav:
- Developing Bots: developing-bots.md
- BalatroBot API: balatrobot-api.md
- Protocol API: protocol-api.md
- Logging Systems: logging-systems.md
markdown_extensions:
- toc:
toc_depth: 3
Expand Down
14 changes: 14 additions & 0 deletions src/balatrobot/client.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Main BalatroBot client for communicating with the game."""

import json
import logging
import socket
from typing import Any, Literal, Self

Expand All @@ -18,6 +19,8 @@
StartRunRequest,
)

logger = logging.getLogger(__name__)


class BalatroClient:
"""Client for communicating with the BalatroBot game API."""
Expand Down Expand Up @@ -58,6 +61,7 @@ def connect(self) -> None:
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)
Expand All @@ -66,7 +70,11 @@ def connect(self) -> None:
)
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",
Expand All @@ -76,6 +84,7 @@ def connect(self) -> None:
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
Expand Down Expand Up @@ -106,6 +115,7 @@ def _send_request(self, name: str, arguments: dict[str, Any]) -> dict[str, Any]:

# Create and validate request
request = APIRequest(name=name, arguments=arguments)
logger.info(f"Sending API request: {name}")
Copy link

Copilot AI Jul 15, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] Consider lowering this to a debug-level log (logger.debug) for routine API messages so INFO-level remains focused on higher-priority events.

Suggested change
logger.info(f"Sending API request: {name}")
logger.debug(f"Sending API request: {name}")

Copilot uses AI. Check for mistakes.

try:
# Send request
Expand All @@ -118,17 +128,21 @@ def _send_request(self, name: str, arguments: dict[str, Any]) -> dict[str, Any]:

# 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.info(f"API request {name} completed successfully")
Copy link

Copilot AI Jul 15, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] Similarly, you may want to use a debug-level log here to reduce noise in INFO-level logs during normal operation.

Suggested change
logger.info(f"API request {name} completed successfully")
logger.debug(f"API request {name} completed successfully")

Copilot uses AI. Check for mistakes.
return response_data

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}")
raise BalatroError(
f"Invalid JSON response from game: {e}",
error_code="E001",
Expand Down
38 changes: 19 additions & 19 deletions src/lua/log.lua
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,14 @@ end

---Logs a function call to the JSONL file
---@param function_name string The name of the function being called
---@param params table The parameters passed to the function
function LOG.write(function_name, params)
---@param arguments table The parameters passed to the function
function LOG.write(function_name, arguments)
---@type LogEntry
local log_entry = {
timestamp_ms = math.floor(socket.gettime() * 1000),
["function"] = {
name = function_name,
params = params,
arguments = arguments,
},
-- game_state before the function call
game_state = utils.get_game_state(),
Expand Down Expand Up @@ -57,9 +57,9 @@ end
function LOG.hook_go_to_menu()
local original_function = G.FUNCS.go_to_menu
G.FUNCS.go_to_menu = function(args)
local params = {}
local arguments = {}
local name = "go_to_menu"
LOG.write(name, params)
LOG.write(name, arguments)
return original_function(args)
end
sendDebugMessage("Hooked into G.FUNCS.go_to_menu for logging", "LOG")
Expand All @@ -77,14 +77,14 @@ function LOG.hook_start_run()
local timestamp = LOG.generate_iso8601_timestamp()
LOG.current_run_file = LOG.mod_path .. "runs/" .. timestamp .. ".jsonl"
sendInfoMessage("Starting new run log: " .. timestamp .. ".jsonl", "LOG")
local params = {
local arguments = {
deck = G.GAME.selected_back.name,
stake = args.stake,
seed = args.seed,
challenge = args.challenge and args.challenge.name,
}
local name = "start_run"
LOG.write(name, params)
LOG.write(name, arguments)
return original_function(game_state, args)
end
sendDebugMessage("Hooked into G.FUNCS.start_run for logging", "LOG")
Expand All @@ -98,9 +98,9 @@ end
function LOG.hook_select_blind()
local original_function = G.FUNCS.select_blind
G.FUNCS.select_blind = function(args)
local params = { action = "select" }
local arguments = { action = "select" }
local name = "skip_or_select_blind"
LOG.write(name, params)
LOG.write(name, arguments)
return original_function(args)
end
sendDebugMessage("Hooked into G.FUNCS.select_blind for logging", "LOG")
Expand All @@ -110,9 +110,9 @@ end
function LOG.hook_skip_blind()
local original_function = G.FUNCS.skip_blind
G.FUNCS.skip_blind = function(args)
local params = { action = "skip" }
local arguments = { action = "skip" }
local name = "skip_or_select_blind"
LOG.write(name, params)
LOG.write(name, arguments)
return original_function(args)
end
sendDebugMessage("Hooked into G.FUNCS.skip_blind for logging", "LOG")
Expand All @@ -132,9 +132,9 @@ function LOG.hook_play_cards_from_highlighted()
table.insert(cards, i - 1) -- Adjust for 0-based indexing
end
end
local params = { action = "play_hand", cards = cards }
local arguments = { action = "play_hand", cards = cards }
local name = "play_hand_or_discard"
LOG.write(name, params)
LOG.write(name, arguments)
return original_function(args)
end
sendDebugMessage("Hooked into G.FUNCS.play_cards_from_highlighted for logging", "LOG")
Expand All @@ -150,9 +150,9 @@ function LOG.hook_discard_cards_from_highlighted()
table.insert(cards, i - 1) -- Adjust for 0-based indexing
end
end
local params = { action = "discard", cards = cards }
local arguments = { action = "discard", cards = cards }
local name = "play_hand_or_discard"
LOG.write(name, params)
LOG.write(name, arguments)
return original_function(args)
end
sendDebugMessage("Hooked into G.FUNCS.discard_cards_from_highlighted for logging", "LOG")
Expand All @@ -166,9 +166,9 @@ end
function LOG.hook_cash_out()
local original_function = G.FUNCS.cash_out
G.FUNCS.cash_out = function(args)
local params = {}
local arguments = {}
local name = "cash_out"
LOG.write(name, params)
LOG.write(name, arguments)
return original_function(args)
end
sendDebugMessage("Hooked into G.FUNCS.cash_out for logging", "LOG")
Expand All @@ -182,9 +182,9 @@ end
function LOG.hook_toggle_shop()
local original_function = G.FUNCS.toggle_shop
G.FUNCS.toggle_shop = function(args)
local params = { action = "next_round" }
local arguments = { action = "next_round" }
local name = "shop"
LOG.write(name, params)
LOG.write(name, arguments)
return original_function(args)
end
sendDebugMessage("Hooked into G.FUNCS.toggle_shop for logging", "LOG")
Expand Down
2 changes: 1 addition & 1 deletion src/lua/types.lua
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@

---@class LogEntry
---@field timestamp_ms number Timestamp in milliseconds since epoch
---@field function {name: string, params: table} Function call information
---@field function {name: string, arguments: table} Function call information
---@field game_state GameStateResponse Game state at time of logging

-- =============================================================================
Expand Down
4 changes: 2 additions & 2 deletions tests/lua/test_runs.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ def test_replay_run(self, tcp_client: socket.socket, jsonl_file: Path) -> None:

# Call the API function with recorded parameters
actual_game_state = send_and_receive_api_message(
tcp_client, function_call["name"], function_call["params"]
tcp_client, function_call["name"], function_call["arguments"]
)

# Compare with the game_state from the next step (if it exists)
Expand All @@ -64,7 +64,7 @@ def test_replay_run(self, tcp_client: socket.socket, jsonl_file: Path) -> None:
# Assert complete game state equality
assert actual_game_state == expected_game_state, (
f"Game state mismatch at step {step_num + 1} in {jsonl_file.name}\n"
f"Function: {function_call['name']}({function_call['params']})\n"
f"Function: {function_call['name']}({function_call['arguments']})\n"
f"Expected: {expected_game_state}\n"
f"Actual: {actual_game_state}"
)
Loading