Mastodon Politics, Power, and Science: Campaign Manager Integration Roadmap

Friday, December 5, 2025

Campaign Manager Integration Roadmap

 J. Rogers, SE Ohio

We already have most of the individual parts coded and working separately, now we just need to make everything work together. 

Wiring Up Your Existing Components


Current Component Inventory

✅ What You Already Have

Component Status Location
World Generation ✅ Working world_gen.py
Town Generator ✅ Working Hooked to AI
Dungeon Generator ✅ Working Hooked to AI
Map Viewer ✅ Working map_viewer.py
Database ✅ Working db_manager.py
UI Widgets ✅ Working widgets.py
Image Rendering ✅ Working image_strategy.py
Heightmap System ✅ Working 16-bit PNG storage

🔧 What Needs Building

Component Priority Purpose
Campaign Manager 🔴 Critical Orchestrates everything
Display Manager 🔴 Critical Dual window control
Navigation System 🔴 Critical Node traversal
Heightmap Extractor 🟡 High Region extraction
Building Designer 🟢 Medium Interior generation
State Synchronization 🟡 High DM ↔ Player sync

Day 1: Core Framework (Morning Session)

Goal: Get the orchestration layer working

Step 1: Create campaign_manager.py

"""
Central orchestrator for the entire campaign system.
Owns all generators, manages state, routes navigation.
"""

from codex_engine.core.db_manager import DBManager
from codex_engine.generators.world_gen import WorldGenerator
from codex_engine.generators.town_gen import TownGenerator  # Your existing
from codex_engine.generators.dungeon_gen import DungeonGenerator  # Your existing

class CampaignManager:
    """
    The brain of the operation. Manages:
    - Current campaign state
    - Navigation between maps
    - Routing to correct generators
    - Database operations
    """
    
    def __init__(self, db_manager, theme_manager):
        self.db = db_manager
        self.theme = theme_manager
        
        # Initialize all generators
        self.world_gen = WorldGenerator(theme_manager, db_manager)
        self.town_gen = TownGenerator(theme_manager, db_manager)
        self.dungeon_gen = DungeonGenerator(theme_manager, db_manager)
        
        # State
        self.current_campaign_id = None
        self.current_node = None
        self.navigation_stack = []  # For back button
        
    def load_campaign(self, campaign_id):
        """Load a campaign and navigate to world map"""
        self.current_campaign_id = campaign_id
        
        # Get or create world map node
        world_node = self.db.get_node_by_coords(campaign_id, None, 0, 0)
        
        if not world_node:
            print("No world map found, generating...")
            node_id, metadata = self.world_gen.generate_world_node(campaign_id)
            world_node = self.db.get_node_by_coords(campaign_id, None, 0, 0)
        
        self.navigate_to_node(world_node)
        
    def navigate_to_node(self, node):
        """Navigate to any node (world/town/dungeon/room)"""
        if self.current_node:
            self.navigation_stack.append(self.current_node)
        
        self.current_node = node
        print(f"Navigated to: {node['name']} (type: {node['node_type']})")
        
        return node
        
    def navigate_back(self):
        """Go back one level in navigation"""
        if self.navigation_stack:
            self.current_node = self.navigation_stack.pop()
            return self.current_node
        return None
        
    def generate_or_load_child(self, parent_node, marker):
        """
        Universal child location loader.
        Routes to correct generator based on marker type.
        """
        # Check if target already exists
        if marker.get('target_node_id'):
            target = self.db.get_node_by_id(marker['target_node_id'])
            if target:
                return self.navigate_to_node(target)
        
        # Doesn't exist, generate based on type
        marker_type = marker['type']
        
        if marker_type == 'town':
            return self._generate_town(parent_node, marker)
        elif marker_type == 'dungeon':
            return self._generate_dungeon(parent_node, marker)
        elif marker_type == 'building':
            return self._generate_building(parent_node, marker)
        else:
            print(f"Unknown marker type: {marker_type}")
            return None
    
    def _generate_town(self, parent_node, marker):
        """Generate a town using your existing town generator"""
        print(f"Generating town at {marker['coords']}...")
        
        # Extract heightmap region from parent
        # (We'll implement this in Step 3)
        
        # Call your existing town generator
        node_id, metadata = self.town_gen.generate_town_node(
            campaign_id=self.current_campaign_id,
            parent_node_id=parent_node['id'],
            coords_x=marker['local_x'],
            coords_y=marker['local_y'],
            town_name=marker.get('name', 'Unnamed Town')
        )
        
        # Update marker to point to new node
        self.db.update_marker(marker['id'], target_node_id=node_id)
        
        # Load and navigate to it
        new_node = self.db.get_node_by_id(node_id)
        return self.navigate_to_node(new_node)
    
    def _generate_dungeon(self, parent_node, marker):
        """Generate a dungeon using your existing dungeon generator"""
        print(f"Generating dungeon at {marker['coords']}...")
        
        node_id, metadata = self.dungeon_gen.generate_dungeon_node(
            campaign_id=self.current_campaign_id,
            parent_node_id=parent_node['id'],
            coords_x=marker['local_x'],
            coords_y=marker['local_y'],
            dungeon_name=marker.get('name', 'Unnamed Dungeon'),
            theme=marker.get('theme', 'generic')
        )
        
        self.db.update_marker(marker['id'], target_node_id=node_id)
        
        new_node = self.db.get_node_by_id(node_id)
        return self.navigate_to_node(new_node)
    
    def _generate_building(self, parent_node, marker):
        """Generate building interior (to be implemented)"""
        print(f"Building generation not yet implemented")
        return None
    
    def get_current_markers(self):
        """Get all markers for current node"""
        if not self.current_node:
            return []
        
        return self.db.get_markers_for_node(self.current_node['id'])
    
    def create_marker(self, marker_type, local_x, local_y, name, secret=False):
        """Create a new marker on current node"""
        if not self.current_node:
            return None
        
        marker_id = self.db.create_marker(
            node_id=self.current_node['id'],
            marker_type=marker_type,
            name=name,
            local_x=local_x,
            local_y=local_y,
            secret=secret
        )
        
        return self.db.get_marker_by_id(marker_id)

Test it:

# In main.py, test basic navigation
cm = CampaignManager(db, theme)
cm.load_campaign(1)
print(f"Current node: {cm.current_node['name']}")

Day 1: Display Manager (Afternoon Session)

Goal: Get dual window rendering working, save this for stand along project 

Step 2: Create display_manager.py

"""
Manages both DM and player display windows.
Handles rendering, state filtering, synchronization.
"""

import pygame
import os

class DisplayManager:
    """
    Controls both display windows and their views.
    DM window has full access, player window is filtered.
    """
    
    def __init__(self, campaign_manager, theme_manager, dm_screen_size=(1920, 1080)):
        self.campaign = campaign_manager
        self.theme = theme_manager
        
        # Create DM window (your existing main window)
        self.dm_screen = pygame.display.set_mode(dm_screen_size)
        pygame.display.set_caption("The Codex Engine - DM View")
        
        # Create player window on second monitor
        self.player_screen = None
        self.player_enabled = False
        
        # Import your existing MapViewer
        from codex_engine.ui.map_viewer import MapViewer
        
        # DM view (full access)
        self.dm_view = MapViewer(self.dm_screen, theme_manager)
        
        # Player view (will be created when enabled)
        self.player_view = None
        
        # Sync state
        self.auto_sync = True  # Push changes to player automatically
        
    def enable_player_display(self):
        """
        Open player display on second monitor.
        Call this when DM clicks "Enable Player Display" button.
        """
        if self.player_enabled:
            return
        
        # Try to position on second monitor
        # This is OS-dependent, might need tweaking
        os.environ['SDL_VIDEO_WINDOW_POS'] = "1920,0"  # Assumes second monitor is to the right
        
        self.player_screen = pygame.display.set_mode((1920, 1080), pygame.NOFRAME)
        pygame.display.set_caption("Campaign Map - Player View")
        
        from codex_engine.ui.map_viewer import MapViewer
        self.player_view = MapViewer(self.player_screen, self.theme)
        
        # Copy current DM state to player (filtered)
        if self.dm_view.current_node:
            self.sync_to_player()
        
        self.player_enabled = True
        print("Player display enabled on second monitor")
    
    def disable_player_display(self):
        """Close player display"""
        if self.player_screen:
            pygame.display.quit()
            self.player_screen = None
            self.player_view = None
            self.player_enabled = False
    
    def sync_to_player(self):
        """
        Push current DM view to player display (filtered).
        This is where you apply fog of war, hide secret markers, etc.
        """
        if not self.player_enabled or not self.player_view:
            return
        
        # Get current node from DM view
        current_node = self.dm_view.current_node
        
        if not current_node:
            return
        
        # Filter the node data for players
        filtered_node = self._filter_node_for_players(current_node)
        
        # Set filtered node on player view
        self.player_view.set_node(filtered_node)
        
        # Sync camera position (optional - or let player view stay centered)
        if self.auto_sync:
            self.player_view.cam_x = self.dm_view.cam_x
            self.player_view.cam_y = self.dm_view.cam_y
            self.player_view.zoom = self.dm_view.zoom
    
    def _filter_node_for_players(self, node):
        """
        Remove secret information from node data.
        Apply fog of war, hide secret markers, etc.
        """
        # Make a copy so we don't modify original
        filtered = node.copy()
        
        # TODO: Apply fog of war from database
        # fog_state = self.campaign.db.get_fog_of_war(node['id'])
        
        # TODO: Filter metadata to remove DM notes
        if 'metadata' in filtered:
            metadata = filtered['metadata'].copy()
            # Remove any keys that start with 'dm_' or 'secret_'
            filtered['metadata'] = {
                k: v for k, v in metadata.items() 
                if not k.startswith('dm_') and not k.startswith('secret_')
            }
        
        return filtered
    
    def handle_dm_input(self, event):
        """Route input to DM view"""
        self.dm_view.handle_input(event)
        
        # Check if DM navigated to a new node
        # If so, sync to player display
        if self.auto_sync and self.dm_view.current_node != self.campaign.current_node:
            self.sync_to_player()
    
    def update_and_render(self):
        """
        Main render loop - draw both windows.
        Call this every frame.
        """
        # Render DM view
        self.dm_view.draw()
        pygame.display.flip()  # Update DM window
        
        # Render player view if enabled
        if self.player_enabled and self.player_view:
            self.player_view.draw()
            # Need to flip the player display too
            # This is tricky with pygame - might need separate display object

Integration challenge: Pygame's pygame.display.set_mode() only supports one window by default. For true dual window, you need to either:

Option A: Use pygame.display.set_mode() twice with separate pygame.init() instances (hacky)

Option B: Use multi-window library like pyglet or moderngl (better, but requires refactoring)

Option C: Use a web-based player view (Pygame for DM, web browser for players on second screen)

For tomorrow, stick with Option A for proof-of-concept:

# Quick dual-window hack for testing
import subprocess

# DM window: Your main pygame app

# Player window: Launch second pygame instance showing same data
# player_process = subprocess.Popen(['python', 'player_display.py'])

Day 2: Navigation Wiring

Goal: Click markers to navigate between maps

Step 3: Wire up marker clicking in map_viewer.py

# Add to MapViewer class

def __init__(self, screen, theme_manager, campaign_manager=None):
    # ... existing code ...
    self.campaign = campaign_manager  # NEW: Reference to campaign manager
    self.markers = []  # NEW: Cache of current markers
    
def set_node(self, node_data):
    # ... existing code ...
    
    # NEW: Load markers for this node
    if self.campaign:
        self.markers = self.campaign.get_current_markers()

def handle_input(self, event):
    # ... existing code ...
    
    # NEW: Handle mouse clicks on markers
    if event.type == pygame.MOUSEBUTTONDOWN:
        if event.button == 1:  # Left click
            clicked_marker = self._get_marker_at_pos(event.pos)
            if clicked_marker:
                self._navigate_to_marker(clicked_marker)

def _get_marker_at_pos(self, screen_pos):
    """Check if mouse clicked on a marker"""
    # Convert screen coordinates to map coordinates
    map_x, map_y = self._screen_to_map_coords(screen_pos)
    
    # Check each marker
    for marker in self.markers:
        marker_x = marker['local_x']
        marker_y = marker['local_y']
        
        # Simple distance check (can improve with hex math later)
        distance = ((map_x - marker_x)**2 + (map_y - marker_y)**2)**0.5
        
        if distance < 20:  # 20 pixel click radius
            return marker
    
    return None

def _screen_to_map_coords(self, screen_pos):
    """Convert screen pixel to map coordinates"""
    from codex_engine.config import SCREEN_WIDTH, SCREEN_HEIGHT
    
    sx, sy = screen_pos
    center_x = SCREEN_WIDTH // 2
    center_y = SCREEN_HEIGHT // 2
    
    map_x = self.cam_x + (sx - center_x) / self.zoom
    map_y = self.cam_y + (sy - center_y) / self.zoom
    
    return map_x, map_y

def _navigate_to_marker(self, marker):
    """Tell campaign manager to navigate to this marker"""
    if not self.campaign:
        return
    
    print(f"Clicked marker: {marker['name']}")
    
    # This triggers generation if needed
    new_node = self.campaign.generate_or_load_child(
        parent_node=self.current_node,
        marker=marker
    )
    
    if new_node:
        # Load the new node
        self.set_node(new_node)

def draw(self):
    # ... existing map rendering code ...
    
    # NEW: Draw markers on top of map
    self._draw_markers()

def _draw_markers(self):
    """Render marker icons on the map"""
    from codex_engine.config import SCREEN_WIDTH, SCREEN_HEIGHT
    
    for marker in self.markers:
        # Skip secret markers if this is player view
        if marker.get('secret') and not self.show_secrets:
            continue
        
        # Convert marker map coords to screen coords
        map_x = marker['local_x']
        map_y = marker['local_y']
        
        screen_x = (SCREEN_WIDTH // 2) + (map_x - self.cam_x) * self.zoom
        screen_y = (SCREEN_HEIGHT // 2) + (map_y - self.cam_y) * self.zoom
        
        # Check if on screen
        if 0 <= screen_x <= SCREEN_WIDTH and 0 <= screen_y <= SCREEN_HEIGHT:
            # Draw marker icon (simple circle for now)
            color = self._get_marker_color(marker['type'])
            pygame.draw.circle(self.screen, color, (int(screen_x), int(screen_y)), 8)
            
            # Draw label
            if self.zoom > 0.5:  # Only show labels when zoomed in
                label = self.font_small.render(marker['name'], True, (255, 255, 255))
                self.screen.blit(label, (screen_x + 12, screen_y - 10))

def _get_marker_color(self, marker_type):
    """Color code markers by type"""
    colors = {
        'town': (100, 200, 100),      # Green
        'dungeon': (200, 100, 100),   # Red
        'building': (200, 200, 100),  # Yellow
        'npc': (100, 100, 200),       # Blue
        'poi': (200, 100, 200),       # Purple
    }
    return colors.get(marker_type, (150, 150, 150))  # Gray default

Day 2: Heightmap Extraction (Afternoon)

Goal: Extract parent terrain for child maps

Step 4: Create terrain_extractor.py

"""
Extracts regions from parent heightmaps and upscales them.
Maintains terrain consistency across zoom levels.
"""

import numpy as np
from PIL import Image
from codex_engine.config import MAPS_DIR

class TerrainExtractor:
    """
    Handles extraction of map regions from parent heightmaps.
    Used when generating towns/dungeons that need to sit on real terrain.
    """
    
    @staticmethod
    def extract_region(parent_map_path, center_x, center_y, radius, target_size):
        """
        Extract a square region from parent heightmap and upscale.
        
        Args:
            parent_map_path: Path to parent heightmap PNG
            center_x, center_y: Center coordinates in parent map
            radius: Half-width of extraction in parent pixels
            target_size: Output resolution (e.g., 1000x1000)
        
        Returns:
            Upscaled heightmap as numpy array (0-1 normalized)
        """
        # Load parent heightmap
        parent_img = Image.open(parent_map_path)
        parent_heightmap = np.array(parent_img, dtype=np.float32) / 65535.0
        
        parent_height, parent_width = parent_heightmap.shape
        
        # Calculate extraction bounds with wrapping
        x_start = int(center_x - radius)
        x_end = int(center_x + radius)
        y_start = int(center_y - radius)
        y_end = int(center_y + radius)
        
        # Handle wrapping (toroidal world)
        if x_start < 0 or x_end >= parent_width or y_start < 0 or y_end >= parent_height:
            # Need to wrap - extract with modulo
            extract_width = x_end - x_start
            extract_height = y_end - y_start
            region = np.zeros((extract_height, extract_width), dtype=np.float32)
            
            for dy in range(extract_height):
                for dx in range(extract_width):
                    src_y = (y_start + dy) % parent_height
                    src_x = (x_start + dx) % parent_width
                    region[dy, dx] = parent_heightmap[src_y, src_x]
        else:
            # Simple slice
            region = parent_heightmap[y_start:y_end, x_start:x_end]
        
        # Upscale using bilinear interpolation
        region_img = Image.fromarray((region * 65535).astype(np.uint16), mode='I;16')
        upscaled_img = region_img.resize((target_size, target_size), Image.BILINEAR)
        upscaled = np.array(upscaled_img, dtype=np.float32) / 65535.0
        
        return upscaled
    
    @staticmethod
    def save_heightmap(heightmap, filename):
        """Save heightmap as 16-bit PNG"""
        uint16_data = (heightmap * 65535).astype(np.uint16)
        img = Image.fromarray(uint16_data, mode='I;16')
        img.save(MAPS_DIR / filename)
        return filename
    
    @staticmethod
    def get_terrain_type(heightmap, sea_level_norm):
        """
        Analyze heightmap to determine terrain type.
        Returns: 'ocean', 'coast', 'plains', 'hills', 'mountains'
        """
        avg_height = np.mean(heightmap)
        
        if avg_height < sea_level_norm - 0.1:
            return 'ocean'
        elif avg_height < sea_level_norm + 0.05:
            return 'coast'
        elif avg_height < 0.4:
            return 'plains'
        elif avg_height < 0.7:
            return 'hills'
        else:
            return 'mountains'

Wire it into your generators:

# In town_gen.py
def generate_town_node(self, campaign_id, parent_node_id, coords_x, coords_y, town_name):
    # Get parent heightmap
    parent_node = self.db.get_node_by_id(parent_node_id)
    parent_map_path = MAPS_DIR / parent_node['metadata']['file_path']
    
    # Extract region
    from codex_engine.terrain.terrain_extractor import TerrainExtractor
    town_heightmap = TerrainExtractor.extract_region(
        parent_map_path,
        center_x=coords_x,
        center_y=coords_y,
        radius=100,  # 200x200 extraction
        target_size=1000  # Upscale to 1000x1000
    )
    
    # Determine terrain type for AI context
    sea_level_norm = (0 - parent_node['metadata']['real_min']) / \
                     (parent_node['metadata']['real_max'] - parent_node['metadata']['real_min'])
    
    terrain_type = TerrainExtractor.get_terrain_type(town_heightmap, sea_level_norm)
    
    # Generate town via AI (your existing code)
    # Pass terrain_type in prompt for context
    town_data = self._generate_via_ai(town_name, terrain_type)
    
    # Save heightmap
    import uuid
    map_filename = f"{uuid.uuid4()}.png"
    TerrainExtractor.save_heightmap(town_heightmap, map_filename)
    
    # Create node
    # ... rest of your existing code

Day 3: Building Designer System

Goal: Generate building interiors themed to campaign setting

Step 5: Create building_designer.py

"""
Procedural building interior generator.
Supports multiple themes: fantasy (D&D), sci-fi (Traveller), modern, etc.
"""

import json
from dataclasses import dataclass
from typing import List, Dict

@dataclass
class RoomTemplate:
    """Template for a room type"""
    name: str
    min_size: tuple  # (width, height) in grid squares
    max_size: tuple
    furniture: List[str]
    required_exits: int
    theme_specific: Dict  # Theme-specific properties

class BuildingDesigner:
    """
    Generates building interiors using themed room templates.
    """
    
    def __init__(self, theme='fantasy'):
        self.theme = theme
        self.room_templates = self._load_room_templates(theme)
    
    def _load_room_templates(self, theme):
        """Load room templates for the given theme"""
        
        if theme == 'fantasy':
            return {
                'tavern_common_room': RoomTemplate(
                    name='Common Room',
                    min_size=(8, 8),
                    max_size=(15, 15),
                    furniture=['tables', 'chairs', 'fireplace', 'bar'],
                    required_exits=2,
                    theme_specific={
                        'lighting': 'firelight',
                        'floor': 'wooden planks',
                        'atmosphere': 'smoky'
                    }
                ),
                'bedroom': RoomTemplate(
                    name='Bedroom',
                    min_size=(3, 3),
                    max_size=(6, 6),
                    furniture=['bed', 'chest', 'nightstand'],
                    required_exits=1,
                    theme_specific={
                        'privacy': True
                    }
                ),
                'kitchen': RoomTemplate(
                    name='Kitchen',
                    min_size=(4, 4),
                    max_size=(8, 8),
                    furniture=['stove', 'counter', 'pantry', 'table'],
                    required_exits=2,
                    theme_specific={
                        'fire_hazard': True
                    }
                ),
                'storage': RoomTemplate(
                    name='Storage Room',
                    min_size=(3, 3),
                    max_size=(6, 6),
                    furniture=['shelves', 'barrels', 'crates'],
                    required_exits=1,
                    theme_specific={}
                ),
            }
        
        elif theme == 'scifi':
            return {
                'bridge': RoomTemplate(
                    name='Bridge',
                    min_size=(10, 8),
                    max_size=(20, 15),
                    furniture=['command_chair', 'consoles', 'viewscreen', 'stations'],
                    required_exits=2,
                    theme_specific={
                        'lighting': 'led panels',
                        'floor': 'metal grating',
                        'tech_level': 'high'
                    }
                ),
                'quarters': RoomTemplate(
                    name='Crew Quarters',
                    min_size=(3, 3),
                    max_size=(5, 5),
                    furniture=['bunk', 'locker', 'terminal'],
                    required_exits=1,
                    theme_specific={
                        'life_support': True
                    }
                ),
                'engine_room': RoomTemplate(
                    name='Engine Room',
                    min_size=(8, 8),
                    max_size=(15, 12),
                    furniture=['reactor', 'control_panels', 'cooling_tanks'],
                    required_exits=2,
                    theme_specific={
                        'radiation': True,
                        'noise_level': 'high'
                    }
                ),
            }
        
        else:
            # Default generic templates
            return {}
    
    def generate_building(self, building_type, size='medium'):
        """
        Generate a complete building interior.
        
        Args:
            building_type: 'inn', 'shop', 'starship', 'warehouse', etc.
            size: 'small', 'medium', 'large'
        
        Returns:
            Building data structure with rooms, connections, furniture
        """
        
        # Define building type → room type mapping
        building_compositions = {
            'fantasy': {
                'inn': ['tavern_common_room', 'bedroom', 'bedroom', 'bedroom', 'kitchen', 'storage'],
                'shop': ['shop_floor', 'storage', 'workshop'],
                'mansion': ['foyer', 'dining_room', 'bedroom', 'bedroom', 'kitchen', 'library', 'study'],
                'smithy': ['forge', 'workshop', 'storage', 'display'],
            },
            'scifi': {
                'starship': ['bridge', 'quarters', 'quarters', 'engine_room', 'cargo_bay', 'medbay'],
                'station': ['command', 'habitation', 'docking', 'maintenance', 'recreation'],
            }
        }
        
        # Get room list for this building type
        if self.theme not in building_compositions:
            print(f"Theme {self.theme} not supported")
            return None
        
        if building_type not in building_compositions[self.theme]:
            print(f"Building type {building_type} not found in theme {self.theme}")
            return None
        
        room_types = building_compositions[self.theme][building_type]
        
        # Generate building structure
        building = {
            'type': building_type,
            'theme': self.theme,
            'size': size,
            'rooms': [],
            'connections': []
        }
        
        # Generate each room
        for i, room_type in enumerate(room_types):
            if room_type in self.room_templates:
                template = self.room_templates[room_type]
                room = self._generate_room(template, room_id=i)
                building['rooms'].append(room)
        
        # Connect rooms (simple linear for now)
        for i in range(len(building['rooms']) - 1):
            building['connections'].append({
                'from_room': i,
                'to_room': i + 1,
                'door_type': 'normal'
            })
        
        return building
    
    def _generate_room(self, template, room_id):
        """Generate a single room from template"""
        import random
        
        # Random size within template bounds
        width = random.randint(template.min_size[0], template.max_size[0])
        height = random.randint(template.min_size[1], template.max_size[1])
        
        room = {
            'id': room_id,
            'name': template.name,

No comments:

Post a Comment

Progress on the campaign manager

You can see that you can build tactical maps automatically from the world map data.  You can place roads, streams, buildings. The framework ...