Phase 1: The Database - The Source of Truth
Add the settings table to _initialize_tables: Python """CREATE TABLE IF NOT EXISTS settings ( id INTEGER PRIMARY KEY AUTOINCREMENT, scope TEXT NOT NULL, -- e.g., 'global', 'campaign' scope_id INTEGER, -- e.g., campaign_id (NULL for global) key TEXT NOT NULL, value TEXT, UNIQUE(scope, scope_id, key) );""",Add new methods to manage these settings: Python # At the end of db_manager.py def get_setting(self, key: str, campaign_id: int = None) -> Optional[str]: """ Gets a setting value, cascading from campaign-specific to global. """ with self.get_connection() as conn: # 1. Try to get campaign-specific setting first if campaign_id: row = conn.execute( "SELECT value FROM settings WHERE scope='campaign' AND scope_id=? AND key=?", (campaign_id, key) ).fetchone() if row: return json.loads(row['value']) # 2. Fallback to global setting row = conn.execute( "SELECT value FROM settings WHERE scope='global' AND key=?", (key,) ).fetchone() if row: return json.loads(row['value']) return None def set_setting(self, key: str, value: Any, scope: str = 'global', scope_id: int = None): """ Saves a setting for a specific scope. """ val_json = json.dumps(value) with self.get_connection() as conn: conn.execute( """INSERT INTO settings (scope, scope_id, key, value) VALUES (?, ?, ?, ?) ON CONFLICT(scope, scope_id, key) DO UPDATE SET value=excluded.value""", (scope, scope_id, key, val_json) ) conn.commit()
Phase 2: The Logic - The Cascade Manager
import osfrom .db_manager import DBManager
class ConfigManager:
def __init__(self, db_manager: DBManager):
self.db = db_manager
self.defaults = {
"ai_provider": "gemini",
"api_key_env_var": "GEMINI_API_KEY",
"base_url": None,
"model_name": None # No default model, force user selection
}
def get(self, key: str, campaign_id: int = None):
"""
Gets a setting, cascading from campaign to global to hardcoded defaults.
"""
value = self.db.get_setting(key, campaign_id)
if value is not None:
return value
return self.defaults.get(key)
def set(self, key: str, value, campaign_id: int = None):
"""
Saves a setting. If campaign_id is provided, saves as campaign-specific.
Otherwise, saves as global.
"""
scope = 'campaign' if campaign_id else 'global'
self.db.set_setting(key, value, scope, campaign_id)
Phase 3: The UI - The Generic Settings Editor
import pygameimport os
from .widgets import Button, InputBox, Dropdown
from ..core.ai_manager import AIManager
from ..core.config_manager import ConfigManager
# Provider definitions remain useful
PROVIDER_DEFAULTS = {
"gemini": {"api_key_env_var": "GEMINI_API_KEY", "base_url": ""},
"openai": {"api_key_env_var": "OPENAI_API_KEY", "base_url": ""},
"ollama": {"api_key_env_var": "", "base_url": "http://localhost:11434/v1"},
"groq": {"api_key_env_var": "GROQ_API_KEY", "base_url": "https://api.groq.com/openai/v1"}
}
class GenericSettingsEditor:
def __init__(self, screen, config_manager: ConfigManager, tab_definitions, scope_id=None):
self.screen = screen
self.config_manager = config_manager
self.tab_definitions = tab_definitions
self.scope_id = scope_id # e.g., campaign_id
# --- UI State ---
self.font_title = pygame.font.Font(None, 48)
self.font_ui = pygame.font.Font(None, 24)
self.active_tab_key = list(self.tab_definitions.keys())[0]
self.widgets = {} # {key: widget_instance}
self.tab_buttons = {}
self.status_text = "Ready."
self.status_color = (200, 200, 200)
self._build_ui_layout()
self.run_loop()
def _build_ui_layout(self):
# ... Main panel, save/cancel buttons ...
self.panel_rect = pygame.Rect(0, 0, 700, 550)
self.panel_rect.center = self.screen.get_rect().center
x, y = self.panel_rect.topleft
# Build Tab Buttons
num_tabs = len(self.tab_definitions)
tab_w = (self.panel_rect.width - 40) / num_tabs
for i, key in enumerate(self.tab_definitions.keys()):
self.tab_buttons[key] = Button(
x + 20 + (i * tab_w), y + 60, tab_w, 30, key, self.font_ui,
(60,60,70), (80,80,90), (255,255,255), lambda k=key: self._set_active_tab(k)
)
self.btn_save = Button(x + 20, self.panel_rect.bottom - 60, 100, 40, "Save", self.font_ui, (50, 150, 50), (80, 200, 80), (255,255,255), self.save_settings)
self.btn_cancel = Button(x + 140, self.panel_rect.bottom - 60, 100, 40, "Cancel", self.font_ui, (150, 50, 50), (200, 80, 80), (255,255,255), None)
self._build_widgets_for_active_tab()
def _set_active_tab(self, key):
self.active_tab_key = key
self._build_widgets_for_active_tab()
def _build_widgets_for_active_tab(self):
self.widgets = {}
widget_defs = self.tab_definitions[self.active_tab_key]
y_offset = 110
for w_def in widget_defs:
key = w_def['key']
label = w_def['label']
w_type = w_def['type']
x, y = self.panel_rect.x + 20, self.panel_rect.y + y_offset
# Draw label for the widget
w_def['label_surface'] = self.font_ui.render(label, True, (200,200,200))
w_def['label_pos'] = (x, y)
y_offset += 25 # Space for the widget itself
current_value = self.config_manager.get(key, self.scope_id)
if w_type == 'input':
self.widgets[key] = InputBox(x, y, 400, 30, self.font_ui, current_value or "")
elif w_type == 'dropdown':
opts = w_def.get('options', [])
self.widgets[key] = Dropdown(x, y, 400, 30, self.font_ui, opts, initial_id=current_value)
elif w_type == 'button':
action = getattr(self, w_def['action'], None) # Find method by name
self.widgets[key] = Button(x, y, 250, 40, label, self.font_ui, (100,100,150), (120,120,180), (255,255,255), action)
y_offset += w_def.get('height', 40) + 20 # Spacing
def save_settings(self):
for key, widget in self.widgets.items():
value = None
if isinstance(widget, InputBox):
value = widget.text
elif isinstance(widget, Dropdown):
value = widget.get_selected_id()
if value is not None:
self.config_manager.set(key, value, self.scope_id)
print("Settings Saved!")
return "close" # Signal to the run_loop
def run_loop(self):
# ... (similar to AISettingsEditor's loop, handling events for self.widgets)
running = True
while running:
# Event Handling
for event in pygame.event.get():
if event.type == pygame.QUIT or (event.type == pygame.KEYDOWN and event.key == pygame.K_ESCAPE):
running = False; return
# Check for Save/Cancel button clicks
if self.btn_save.handle_event(event) == "close":
running = False; return
if event.type == pygame.MOUSEBUTTONDOWN and self.btn_cancel.rect.collidepoint(event.pos):
running = False; return
# Handle dynamic widgets
for btn in self.tab_buttons.values(): btn.handle_event(event)
for key, widget in self.widgets.items():
res = widget.handle_event(event)
if key == "ai_provider" and res: self._on_provider_change()
# Drawing
self.draw()
pygame.display.flip()
pygame.time.Clock().tick(30)
# --- ACTION METHODS FOR BUTTONS ---
def test_ai_connection(self):
provider = self.widgets['ai_provider'].get_selected_id()
key_var = self.widgets['api_key_env_var'].text
base_url = self.widgets['base_url'].text or None
self.status_text = f"Testing {provider}..."
self.status_color = (200, 200, 100)
self.draw(); pygame.display.flip() # Force redraw
success, data = AIManager.test_and_get_models(provider, key_var, base_url)
if success:
self.status_text = f"Success! Found {len(data)} models."
self.status_color = (100, 200, 100)
self.widgets['model_name'].options = [{"id": m, "name": m} for m in data]
else:
self.status_text = f"Error: {data}"
self.status_color = (200, 100, 100)
self.widgets['model_name'].options = []
self.widgets['model_name'].selected_idx = -1
def _on_provider_change(self):
"""Auto-fill defaults when provider dropdown changes."""
provider = self.widgets['ai_provider'].get_selected_id()
defaults = PROVIDER_DEFAULTS.get(provider, {})
self.widgets['api_key_env_var'].text = defaults.get('api_key_env_var', "")
self.widgets['base_url'].text = defaults.get('base_url', "") or ""
self.widgets['model_name'].options = []
self.widgets['model_name'].selected_idx = -1
self.status_text = "Provider changed. Please test connection."
self.status_color = (200, 200, 200)
def draw(self):
# Overlay and panel
overlay = pygame.Surface(self.screen.get_size(), pygame.SRCALPHA)
overlay.fill((0, 0, 0, 150))
self.screen.blit(overlay, (0,0))
pygame.draw.rect(self.screen, (40, 40, 50), self.panel_rect, border_radius=10)
pygame.draw.rect(self.screen, (100, 100, 120), self.panel_rect, 2, border_radius=10)
x, y = self.panel_rect.topleft
# Title
title_text = "Global Settings" if not self.scope_id else f"Campaign Settings"
title_surf = self.font_title.render(title_text, True, (255, 255, 255))
self.screen.blit(title_surf, (x + 20, y + 15))
# Tabs
for key, btn in self.tab_buttons.items():
btn.base_color = (80, 80, 90) if key == self.active_tab_key else (60, 60, 70)
btn.draw(self.screen)
# Widgets and labels
widget_defs = self.tab_definitions[self.active_tab_key]
for w_def in widget_defs:
self.screen.blit(w_def['label_surface'], w_def['label_pos'])
widget = self.widgets.get(w_def['key'])
if widget:
widget.draw(self.screen)
# Status text for AI tab
if self.active_tab_key == "AI":
status_surf = self.font_ui.render(self.status_text, True, self.status_color)
self.screen.blit(status_surf, (self.panel_rect.x + 280, self.panel_rect.bottom - 100))
# Save/Cancel
self.btn_save.draw(self.screen)
self.btn_cancel.draw(self.screen)
Phase 4: Application Integration
# --- IMPORTS ---from codex_engine.core.db_manager import DBManager
from codex_engine.core.config_manager import ConfigManager # <-- Add
from codex_engine.core.ai_manager import AIManager # <-- Add
from codex_engine.ui.settings_editor import GenericSettingsEditor, PROVIDER_DEFAULTS # <-- Add
# ... other imports
class CodexApp:
def __init__(self):
# ... existing setup ...
self.db = DBManager()
self.config = ConfigManager(self.db) # <-- Create ConfigManager
self.ai = AIManager(self.config) # <-- Pass it to AIManager
self.state = "MENU"
self.current_campaign = None
self.menu_screen = CampaignMenu(self.screen, self.db, self.config) # <-- Pass config
self.map_viewer = None
# ...
def load_campaign(self, campaign_id, theme_id):
# ...
# --- RECONFIGURE AI FOR CAMPAIGN CONTEXT ---
self.ai.reconfigure_for_campaign(campaign_id)
# ---
if not self.map_viewer:
# Pass campaign-aware AI to MapViewer
self.map_viewer = MapViewer(self.screen, self.theme_manager, self.ai)
# ...
class AIManager: def __init__(self, config_manager: ConfigManager): # <-- Change
self.config_manager = config_manager
self.provider = None
self.reconfigure_for_campaign(None) # Load global config initially
def reconfigure_for_campaign(self, campaign_id: int = None):
provider_name = self.config_manager.get("ai_provider", campaign_id)
# ... rest of the loading logic from Phase 4 ...
# In CampaignMenu __init__self.config = config_manager
self.btn_settings = Button(..., self.open_settings)
def open_settings(self):
# This defines the tabs and widgets for the Global settings panel
ai_tab_def = [
{'type': 'dropdown', 'label': 'AI Provider:', 'key': 'ai_provider',
'options': [{"id": k, "name": k.title()} for k in PROVIDER_DEFAULTS.keys()]},
{'type': 'input', 'label': 'API Key Environment Variable:', 'key': 'api_key_env_var'},
{'type': 'input', 'label': 'Base URL:', 'key': 'base_url'},
{'type': 'button', 'label': 'Test & Fetch Models', 'key': 'test_button', 'action': 'test_ai_connection'},
{'type': 'dropdown', 'label': 'Selected Model:', 'key': 'model_name', 'options': []}
]
tab_definitions = {
"AI": ai_tab_def,
"General": [ # Example of another tab
{'type': 'input', 'label': 'GM Name:', 'key': 'gm_name'}
]
}
# Launch the generic editor with a GLOBAL scope (scope_id=None)
GenericSettingsEditor(self.screen, self.config, tab_definitions)
The main menu configures Global settings.You can add a button inside the MapViewer to open the same editor but pass scope_id=self.current_campaign['id']. The AIManager will automatically use the campaign-specific model for your Noir game, and the global default for your D&D game.
No comments:
Post a Comment