balatro-rl/ai/utils/communication.py

193 lines
6.9 KiB
Python

"""
Balatro Dual Pipe Communication Abstraction
Pure dual pipe I/O layer with persistent handles for communicating with Balatro mod.
Handles low-level pipe operations without any game logic or AI logic.
This abstraction can be used by:
- balatro_env.py (for SB3 training)
- file_watcher.py (for testing)
- Any other component that needs to talk to Balatro
"""
import json
import logging
import os
from typing import Dict, Any, Optional
class BalatroPipeIO:
"""
Clean abstraction for dual pipe communication with Balatro mod using persistent handles
Responsibilities:
- Read JSON requests from Balatro mod via request pipe
- Write JSON responses back to Balatro mod via response pipe
- Keep pipe handles open persistently to avoid deadlocks
- Provide simple, clean interface for higher-level code
"""
def __init__(self, request_pipe: str = "/tmp/balatro_request", response_pipe: str = "/tmp/balatro_response"):
self.request_pipe = request_pipe
self.response_pipe = response_pipe
self.logger = logging.getLogger(__name__)
# Persistent pipe handles
self.request_handle = None
self.response_handle = None
# Create pipes and open persistent handles
self.create_pipes()
self.open_persistent_handles()
def create_pipes(self) -> None:
"""
Create dual named pipes for communication
Creates both request and response pipes if they don't exist.
Safe to call multiple times.
"""
for pipe_path in [self.request_pipe, self.response_pipe]:
try:
# Remove existing pipe if it exists
if os.path.exists(pipe_path):
os.unlink(pipe_path)
self.logger.debug(f"Removed existing pipe: {pipe_path}")
# Create named pipe
os.mkfifo(pipe_path)
self.logger.info(f"Created pipe: {pipe_path}")
except Exception as e:
self.logger.error(f"Failed to create pipe {pipe_path}: {e}")
raise RuntimeError(f"Could not create communication pipe: {pipe_path}")
def open_persistent_handles(self) -> None:
"""
Open persistent handles for reading and writing
Opens request pipe for reading and response pipe for writing.
Keeps handles open to avoid deadlocks.
"""
import time
try:
self.logger.info(f"🔧 Waiting for Balatro to connect...")
self.logger.info(f" Press 'R' in Balatro now to activate RL training!")
# Open request pipe for reading (Balatro writes to this)
self.request_handle = open(self.request_pipe, 'r')
# Open response pipe for writing (AI writes to this)
self.response_handle = open(self.response_pipe, 'w')
except Exception as e:
self.logger.error(f"Failed to open persistent handles: {e}")
self.cleanup_handles()
raise RuntimeError(f"Could not open persistent pipe handles: {e}")
def wait_for_request(self) -> Optional[Dict[str, Any]]:
"""
Wait for new request from Balatro mod using persistent handle
Blocks until Balatro writes a request to the pipe.
No timeout needed - pipes block until data arrives.
Returns:
Parsed JSON request data, or None if error
"""
if not self.request_handle:
self.logger.error("Request handle not open")
return None
try:
# Read from persistent request handle
request_line = self.request_handle.readline().strip()
if not request_line:
return None
request_data = json.loads(request_line)
self.logger.info(f"📥 RECEIVED REQUEST: {request_line}...") # Show first 100 chars
return request_data
except json.JSONDecodeError as e:
self.logger.error(f"Invalid JSON in request: {e}")
return None
except Exception as e:
self.logger.error(f"Error reading from request pipe: {e}")
return None
def send_response(self, response_data: Dict[str, Any]) -> bool:
"""
Send response back to Balatro mod using persistent handle
Writes response data to response pipe for Balatro to read.
Args:
response_data: Response dictionary to send
Returns:
True if successful, False if error
"""
if not self.response_handle:
self.logger.error("Response handle not open")
return False
try:
# Write to persistent response handle
json.dump(response_data, self.response_handle)
self.response_handle.write('\n') # Important: newline for pipe communication
self.response_handle.flush() # Force write to pipe immediately
self.logger.info(f"📤 SENT RESPONSE: {json.dumps(response_data)}")
return True
except Exception as e:
self.logger.error(f"❌ ERROR sending response: {e}")
import traceback
self.logger.error(f"❌ TRACEBACK: {traceback.format_exc()}")
return False
def cleanup_handles(self):
"""
Close persistent pipe handles
Closes the open file handles.
"""
if self.request_handle:
try:
self.request_handle.close()
self.logger.debug("Closed request handle")
except Exception as e:
self.logger.warning(f"Failed to close request handle: {e}")
self.request_handle = None
if self.response_handle:
try:
self.response_handle.close()
self.logger.debug("Closed response handle")
except Exception as e:
self.logger.warning(f"Failed to close response handle: {e}")
self.response_handle = None
def cleanup(self):
"""
Clean up communication pipes and handles
Closes handles and removes pipe files from the filesystem.
"""
# Close handles first
self.cleanup_handles()
# Remove pipe files
for pipe_path in [self.request_pipe, self.response_pipe]:
try:
if os.path.exists(pipe_path):
os.unlink(pipe_path)
self.logger.debug(f"Removed pipe: {pipe_path}")
except Exception as e:
self.logger.warning(f"Failed to remove pipe {pipe_path}: {e}")
self.logger.debug("Dual pipe communication cleanup complete")