Rummage - MTG Commander Game Engine Documentation

Welcome to the official documentation for Rummage, a robust end-to-end tested Magic: The Gathering Commander format game engine built with Bevy 0.15.x.

About Rummage

Rummage is a modern, open-source implementation of the Magic: The Gathering Commander format, focusing on correctness, performance, and extensibility. Built on Bevy's Entity Component System (ECS) architecture, Rummage provides a solid foundation for complex card interactions while maintaining deterministic gameplay essential for networked multiplayer.

Our goal is to create a comprehensive digital implementation that faithfully reproduces the Commander experience while leveraging modern game engine technologies.

Documentation Structure

The documentation is organized into interconnected sections that guide you from understanding MTG rules to technical implementation details:

  1. MTG Rules Reference - High-level explanations of Magic: The Gathering rules, serving as a bridge between official rules and our implementation
  2. MTG Core Rules - Implementation of fundamental Magic: The Gathering rules that form the foundation of all gameplay
  3. Game Formats - Format-specific rules implementation, currently focusing on the Commander format
  4. Game UI - User interface systems for visualizing and interacting with the game state
  5. Networking - Multiplayer functionality using bevy_replicon for synchronized gameplay
  6. Card Systems - Card representation, effects, and interactions that drive gameplay
  7. Testing - Comprehensive testing framework to ensure rule correctness and system reliability
  8. Development - Guidelines and tools for contributors to the Rummage project
  9. API Reference - Technical documentation of Rummage's code structure and interfaces

These sections follow a logical progression:

  • The MTG Rules Reference explains what the rules are
  • The MTG Core Rules and Game Formats explain how we implement these rules
  • The remaining sections cover the technical systems that support this implementation

Getting Started

If you're new to the project, we recommend exploring the documentation in this order:

  1. MTG Core Rules Overview - Understand how Rummage implements the fundamental MTG rules
  2. Commander Format Overview - Learn about the Commander-specific rules and mechanics
  3. Development Guide - Set up your development environment
  4. Bevy ECS Guide - Learn how we use Bevy's Entity Component System
  5. Testing Overview - Understand our testing approach and methodology

Technical Architecture

Rummage integrates several key technologies:

  1. Bevy 0.15.x - Entity Component System (ECS) game engine that provides the architectural foundation
  2. Bevy Replicon - Networking and state synchronization for multiplayer gameplay
  3. Rust - Memory-safe, high-performance language for reliable game logic

Our architecture follows these principles:

  1. Entity Component System - Game elements are composed of entities with components, providing a flexible and performant structure for representing cards, players, and game state
  2. Event-driven Architecture - Systems communicate through events, enabling loose coupling and flexible interactions
  3. Data-oriented Design - Optimized for cache coherence and performance, critical for handling complex board states
  4. Deterministic Game Logic - Ensures consistency across network play by maintaining predictable state transitions
  5. Snapshot System - Enables game state serialization for networking, replays, and save/load functionality

Implementation Status

This documentation represents both implemented features and design specifications for planned features. Components are marked as follows:

  • ✅ Implemented and tested
  • 🔄 In progress
  • ⚠️ Planned but not yet implemented

Development Standards

The Rummage codebase adheres to the following standards:

  1. Bevy 0.15.x Compatibility: Using non-deprecated Bevy APIs (e.g., Text2d instead of Text2dBundle)
  2. End-to-End Testing: Comprehensive test coverage for all features
  3. Documentation-First Development: New features are documented before implementation
  4. Performance Focus: Optimization for smooth gameplay even with complex board states

Contributing

If you're interested in contributing to the Rummage project, please review:

  1. Contribution Guidelines
  2. Documentation Guide
  3. Code Style Guide

Reference Materials

The implementation is based on official MTG rules:

A local copy of the comprehensive rules is available in this repository: MagicCompRules 20250207.txt.


This documentation will evolve as the project progresses. Last updated: March 2025.

MTG Core Rules

This section documents the implementation of fundamental Magic: The Gathering rules in the Rummage engine.

Overview

The MTG Core Rules module implements the foundational mechanics defined in the Magic: The Gathering Comprehensive Rules, forming the basis for all supported game formats. These include:

  • Turn structure and phase sequence
  • Card types, characteristics, and properties
  • Game zones and zone transitions
  • Stack implementation and resolution mechanics
  • State-based actions
  • Combat system
  • Mana and casting costs

Implementation Architecture

Rummage implements MTG rules using Bevy's Entity Component System (ECS) architecture:

MTG ConceptECS Representation
Cards, permanents, playersEntities
Card characteristics, statesComponents
Rules procedures, actionsSystems
Game transitions, triggersEvents

This architecture creates a clean separation between game data and logic, enabling:

  • Higher testability of individual game mechanics
  • Parallel processing of independent game systems
  • Easier extension for format-specific rules
  • Greater code modularity and maintainability

For implementation details, see ECS Implementation.

Core Game Elements

Turn Structure

MTG turns follow a fixed sequence of phases and steps:

  1. Beginning Phase

    • Untap: Active player untaps their permanents
    • Upkeep: Triggers "at beginning of upkeep" abilities
    • Draw: Active player draws a card
  2. Pre-Combat Main Phase

    • Player may play lands and cast spells
  3. Combat Phase

    • Beginning of Combat: Last chance for effects before attacks
    • Declare Attackers: Active player declares attacking creatures
    • Declare Blockers: Defending players assign blockers
    • Combat Damage: Creatures deal damage
    • End of Combat: Triggers "at end of combat" abilities
  4. Post-Combat Main Phase

    • Player may play lands (if not done in first main phase) and cast spells
  5. Ending Phase

    • End Step: Triggers "at beginning of end step" abilities
    • Cleanup: Damage clears, "until end of turn" effects end

Each phase transition is implemented as a state change, with systems that execute appropriate actions for each phase.

Game Zones

MTG defines distinct zones where cards can exist:

ZoneDescriptionImplementation
LibraryPlayer's deckOrdered collection, face-down
HandCards held by a playerPrivate collection
BattlefieldCards in playPublic collection with positioning
GraveyardDiscarded/destroyed cardsOrdered collection
StackSpells being cast, abilities being activatedLIFO data structure
ExileCards removed from gamePublic collection
CommandFormat-specific zone (e.g., Commanders)Special collection

Each zone is implemented as an entity with components that track contained cards. Zone transitions trigger specific events and state updates.

Card Types and Characteristics

The engine supports all standard MTG card types:

  • Land: Produces mana
  • Creature: Can attack and block
  • Artifact: Represents magical items
  • Enchantment: Ongoing magical effects
  • Planeswalker: Powerful ally with loyalty abilities
  • Instant: One-time effect at any time
  • Sorcery: One-time effect during main phase

Cards are represented as entities with components describing their characteristics (name, types, mana cost, etc.) and current state.

Stack and Priority

The stack is MTG's core mechanic for resolving spells and abilities:

  1. Active player receives priority first in each step/phase
  2. Players may cast spells/activate abilities when they have priority
  3. Spells/abilities go on the stack when cast/activated
  4. When all players pass priority consecutively, the top item on the stack resolves
  5. After resolution, active player receives priority again

Our implementation uses a dedicated stack system integrated with a priority manager that tracks which player can act.

State-Based Actions

State-based actions are automatic game rules that check and apply whenever a player would receive priority:

  • Creatures with toughness ≤ 0 are put into their owners' graveyards
  • Players with life ≤ 0 lose the game
  • Auras without legal targets are put into owners' graveyards
  • Legendary permanents with the same name are put into graveyards
  • And many more...

These are implemented as systems that run at specific points in the game loop to enforce rules consistency.

Format Extensibility

The core rules are implemented in a format-agnostic way to enable:

  1. Consistent Base Behavior: All formats share the same fundamental mechanics
  2. Extension Points: Format-specific plugins can override or extend core behavior
  3. Configuration: Format-specific parameters (starting life, deck requirements, etc.)

For Commander-specific implementations that build upon these core rules, see the Commander Format section.

Technical Components

The core rules implementation includes these key technical components:

  • Turn Manager: Controls phase/step progression
  • Zone Manager: Handles card movement between zones
  • Stack Resolution Engine: Manages spell/ability resolution
  • State-Based Action Checker: Enforces automatic game rules
  • Combat Resolver: Handles attack/block/damage processes
  • Mana System: Tracks and processes mana production/consumption

Each component is implemented as a Bevy plugin that adds relevant systems, components, and resources.

Next Steps

Turn Structure

This section documents the core implementation of Magic: The Gathering's turn structure and phase system in Rummage.

Overview

The turn structure in Magic: The Gathering follows a specific sequence of phases and steps, during which players can take actions at defined points. This structured progression is essential for maintaining game order and ensuring rules consistency.

Turn Phases and Steps

A turn in MTG consists of the following phases and steps, in order:

  1. Beginning Phase

    • Untap Step - Active player untaps their permanents
    • Upkeep Step - Triggers "at the beginning of your upkeep" abilities
    • Draw Step - Active player draws a card
  2. First Main Phase (Precombat Main Phase)

    • Player may play lands, cast spells, and activate abilities
  3. Combat Phase

    • Beginning of Combat Step - Last chance for effects before attackers
    • Declare Attackers Step - Active player declares attackers
    • Declare Blockers Step - Defending players declare blockers
    • Combat Damage Step - Combat damage is assigned and dealt
    • End of Combat Step - Last chance for effects before combat ends
  4. Second Main Phase (Postcombat Main Phase)

    • Player may play lands (if they haven't during this turn), cast spells, and activate abilities
  5. Ending Phase

    • End Step - Triggers "at the beginning of your end step" abilities
    • Cleanup Step - Discard to hand size, damage wears off

Implementation Details

Phase Tracking

#![allow(unused)]
fn main() {
#[derive(Resource, Debug, Clone, PartialEq, Eq)]
pub enum Phase {
    Beginning(BeginningPhaseStep),
    Main(MainPhaseType),
    Combat(CombatStep),
    Ending(EndingPhaseStep),
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BeginningPhaseStep {
    Untap,
    Upkeep,
    Draw,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MainPhaseType {
    Precombat,
    Postcombat,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CombatStep {
    Beginning,
    DeclareAttackers,
    DeclareBlockers,
    CombatDamage,
    End,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum EndingPhaseStep {
    End,
    Cleanup,
}
}

Phase Progression System

The phase progression system handles the advancement through turn phases:

#![allow(unused)]
fn main() {
pub fn advance_phase(
    mut game_state: ResMut<GameState>,
    mut phase_events: EventWriter<PhaseChangeEvent>,
) {
    let current_phase = game_state.current_phase.clone();
    let next_phase = determine_next_phase(&current_phase);
    
    game_state.current_phase = next_phase.clone();
    
    phase_events.send(PhaseChangeEvent {
        previous_phase: current_phase,
        new_phase: next_phase,
    });
}

fn determine_next_phase(current_phase: &Phase) -> Phase {
    match current_phase {
        Phase::Beginning(step) => match step {
            BeginningPhaseStep::Untap => Phase::Beginning(BeginningPhaseStep::Upkeep),
            BeginningPhaseStep::Upkeep => Phase::Beginning(BeginningPhaseStep::Draw),
            BeginningPhaseStep::Draw => Phase::Main(MainPhaseType::Precombat),
        },
        Phase::Main(MainPhaseType::Precombat) => Phase::Combat(CombatStep::Beginning),
        Phase::Combat(step) => match step {
            CombatStep::Beginning => Phase::Combat(CombatStep::DeclareAttackers),
            CombatStep::DeclareAttackers => Phase::Combat(CombatStep::DeclareBlockers),
            CombatStep::DeclareBlockers => Phase::Combat(CombatStep::CombatDamage),
            CombatStep::CombatDamage => Phase::Combat(CombatStep::End),
            CombatStep::End => Phase::Main(MainPhaseType::Postcombat),
        },
        Phase::Main(MainPhaseType::Postcombat) => Phase::Ending(EndingPhaseStep::End),
        Phase::Ending(step) => match step {
            EndingPhaseStep::End => Phase::Ending(EndingPhaseStep::Cleanup),
            EndingPhaseStep::Cleanup => Phase::Beginning(BeginningPhaseStep::Untap),
        },
    }
}
}

Priority System

During most phases and steps, players receive priority to take actions:

#![allow(unused)]
fn main() {
pub fn handle_priority(
    game_state: Res<GameState>,
    mut stack: ResMut<Stack>,
    mut priority_events: EventWriter<PriorityEvent>,
) {
    // Skip priority in certain steps
    if !should_players_get_priority(&game_state.current_phase) {
        return;
    }
    
    // Determine priority holder
    let active_player = game_state.active_player;
    let next_player = determine_priority_player(active_player, &game_state.player_order);
    
    priority_events.send(PriorityEvent {
        player: next_player,
        phase: game_state.current_phase.clone(),
    });
}

fn should_players_get_priority(phase: &Phase) -> bool {
    match phase {
        Phase::Beginning(BeginningPhaseStep::Untap) => false,
        Phase::Ending(EndingPhaseStep::Cleanup) => false,
        _ => true,
    }
}
}

Extension Points

The turn structure system is designed to be extensible for different formats:

  1. Format-Specific Phases - Formats can add custom phases or steps
  2. Turn Modification - Systems for extra turns, skipped turns, or additional phases
  3. Priority Rules - Customizable priority passing for different formats

Integration with Other Systems

The turn structure integrates with these other game systems:

  • State-Based Actions - Checked whenever a player would receive priority
  • Stack Resolution - Occurs during priority passes when no player adds to the stack
  • Triggered Abilities - Put on the stack at appropriate times based on the current phase
  • Mana System - Mana pools empty at phase boundaries

Format-Specific Considerations

Different Magic formats may implement turn structure variations:

  • Multiplayer Formats - Handle turn order for multiple players
  • Special Formats - May modify turn structure (e.g., Two-Headed Giant)

For Commander-specific turn structure implementation, see Commander Turn Structure.


Next: Priority System

Priority System

This document details the implementation of the priority system in Rummage, which determines which player can take actions at any given time during a game of Magic: The Gathering.

Overview

The priority system is a fundamental part of Magic: The Gathering's turn structure. It determines when players can cast spells, activate abilities, and take other game actions. Understanding and correctly implementing the priority system is essential for proper game flow.

Core Priority Rules

The basic rules of priority in MTG are:

  1. The active player receives priority first in each step and phase
  2. When a player has priority, they may:
    • Cast a spell
    • Activate an ability
    • Take a special action
    • Pass priority
  3. When a player passes priority, the next player in turn order receives priority
  4. When all players pass priority in succession:
    • If the stack is empty, the current step or phase ends
    • If the stack has objects, the top object on the stack resolves, then the active player gets priority again

Implementation

In Rummage, the priority system is implemented as follows:

#![allow(unused)]
fn main() {
#[derive(Resource)]
pub struct PrioritySystem {
    // The player who currently has priority
    pub current_player: Entity,
    
    // Set of players who have passed priority in succession
    pub passed_players: HashSet<Entity>,
    
    // Whether the priority system is currently active
    pub active: bool,
}

#[derive(Event)]
pub struct PriorityEvent {
    pub player: Entity,
    pub action: PriorityAction,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PriorityAction {
    Receive,  // Player receives priority
    Pass,     // Player passes priority
    TakeAction, // Player takes an action (cast spell, activate ability, etc.)
}
}

Priority Flow

The flow of priority follows this pattern:

  1. Phase/Step Start: At the beginning of each phase or step, the active player receives priority
  2. Action Taken: If a player takes an action, all players who have passed are reset, and priority returns to the active player
  3. Passing: When a player passes, the next player in turn order receives priority
  4. Resolution: When all players pass in succession, either the top of the stack resolves or the phase/step ends

Priority System Implementation

#![allow(unused)]
fn main() {
pub fn handle_priority_system(
    mut commands: Commands,
    mut priority: ResMut<PrioritySystem>,
    mut priority_events: EventReader<PriorityEvent>,
    game_state: Res<GameState>,
    stack: Res<Stack>,
) {
    for event in priority_events.iter() {
        match event.action {
            PriorityAction::Pass => {
                // Record that this player passed
                priority.passed_players.insert(event.player);
                
                // Check if all players have passed
                if priority.passed_players.len() == game_state.players.len() {
                    // All players have passed
                    if !stack.items.is_empty() {
                        // Resolve top of stack
                        commands.add(resolve_stack_command());
                        
                        // Reset passed players
                        priority.passed_players.clear();
                        
                        // Active player gets priority again
                        priority.current_player = game_state.active_player;
                    } else {
                        // Stack is empty, end current phase/step
                        commands.add(advance_phase_command());
                        
                        // Priority will be set by the phase transition system
                        priority.active = false;
                    }
                } else {
                    // Not all players have passed, give priority to next player
                    let next_player = get_next_player(event.player, &game_state);
                    priority.current_player = next_player;
                }
            },
            PriorityAction::TakeAction => {
                // Player took an action, reset passed players
                priority.passed_players.clear();
                
                // Active player gets priority again
                priority.current_player = game_state.active_player;
            },
            PriorityAction::Receive => {
                // Player receives priority (usually at beginning of phase/step)
                priority.current_player = event.player;
                priority.active = true;
            }
        }
    }
}
}

Special Priority Rules

No Priority Phases

Some steps do not normally grant players priority:

  • Untap Step: No player receives priority during this step
  • Cleanup Step: No player receives priority unless a triggered ability triggers
#![allow(unused)]
fn main() {
pub fn should_grant_priority(phase: Phase, step: Step) -> bool {
    match (phase, step) {
        (Phase::Beginning, Step::Untap) => false,
        (Phase::Ending, Step::Cleanup) => false,
        _ => true,
    }
}
}

Triggered Abilities During No-Priority Steps

If a triggered ability triggers during a step where players don't normally receive priority, players will receive priority:

#![allow(unused)]
fn main() {
pub fn handle_cleanup_triggers(
    mut commands: Commands,
    mut priority: ResMut<PrioritySystem>,
    game_state: Res<GameState>,
    triggers: Res<TriggeredAbilities>,
) {
    // If we're in cleanup step and there are triggers
    if game_state.current_phase == Phase::Ending && 
       game_state.current_step == Step::Cleanup &&
       !triggers.pending.is_empty() {
        
        // Grant priority to active player
        priority.active = true;
        priority.current_player = game_state.active_player;
        priority.passed_players.clear();
    }
}
}

APNAP Order

When multiple players would receive priority simultaneously (such as for triggered abilities), they are processed in APNAP (Active Player, Non-Active Player) order:

#![allow(unused)]
fn main() {
pub fn get_players_in_apnap_order(game_state: &GameState) -> Vec<Entity> {
    let mut players = Vec::new();
    
    // Start with active player
    let mut current = game_state.active_player;
    players.push(current);
    
    // Add remaining players in turn order
    for _ in 1..game_state.players.len() {
        current = get_next_player(current, game_state);
        players.push(current);
    }
    
    players
}
}

Integration with Other Systems

The priority system integrates with:

  1. Turn Structure: Phase and step transitions affect priority
  2. Stack System: Stack resolution and priority are tightly coupled
  3. Action System: Player actions affect priority flow
  4. UI System: The UI must indicate which player has priority

Implementation Status

The priority system implementation currently:

  • ✅ Handles basic priority passing
  • ✅ Integrates with stack resolution
  • ✅ Implements APNAP order
  • ✅ Handles special steps without priority
  • ✅ Supports triggered abilities during cleanup
  • 🔄 Implementing special actions that don't use the stack
  • 🔄 Handling priority with split second spells

Next: Turn Phases

Turn Phases

This document details the implementation of turn phases and steps in Rummage, explaining how the game progresses through the structured sequence of a Magic: The Gathering turn.

Phase and Step Structure

A turn in Magic: The Gathering consists of five phases, some of which are divided into steps:

#![allow(unused)]
fn main() {
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Phase {
    Beginning,
    PreCombatMain,
    Combat,
    PostCombatMain,
    Ending,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Step {
    // Beginning Phase
    Untap,
    Upkeep,
    Draw,
    
    // Main Phase (no steps)
    Main,
    
    // Combat Phase
    BeginningOfCombat,
    DeclareAttackers,
    DeclareBlockers,
    CombatDamage,
    FirstStrikeDamage, // Only used when first/double strike is involved
    EndOfCombat,
    
    // Ending Phase
    End,
    Cleanup,
}
}

Phase Transitions

The game progresses through phases and steps in a specific order. This is managed by a phase transition system:

#![allow(unused)]
fn main() {
pub fn phase_transition_system(
    mut commands: Commands,
    mut game_state: ResMut<GameState>,
    mut phase_events: EventWriter<PhaseChangeEvent>,
    mut step_events: EventWriter<StepChangeEvent>,
    stack: Res<Stack>,
    priority: Res<PrioritySystem>,
) {
    // Only transition if priority is not active and stack is empty
    if priority.active || !stack.items.is_empty() {
        return;
    }
    
    // Current phase and step
    let current_phase = game_state.current_phase;
    let current_step = game_state.current_step;
    
    // Determine next phase and step
    let (next_phase, next_step) = get_next_phase_step(current_phase, current_step);
    
    // Update game state
    game_state.current_phase = next_phase;
    game_state.current_step = next_step;
    
    // Send events
    if current_phase != next_phase {
        phase_events.send(PhaseChangeEvent {
            previous: current_phase,
            current: next_phase,
        });
    }
    
    step_events.send(StepChangeEvent {
        previous: current_step,
        current: next_step,
    });
    
    // Execute phase/step entry actions
    execute_phase_entry_actions(next_phase, next_step, &mut commands, &game_state);
}
}

Phase Progression

The progression from one phase/step to the next follows this pattern:

#![allow(unused)]
fn main() {
fn get_next_phase_step(current_phase: Phase, current_step: Step) -> (Phase, Step) {
    match (current_phase, current_step) {
        // Beginning Phase progression
        (Phase::Beginning, Step::Untap) => (Phase::Beginning, Step::Upkeep),
        (Phase::Beginning, Step::Upkeep) => (Phase::Beginning, Step::Draw),
        (Phase::Beginning, Step::Draw) => (Phase::PreCombatMain, Step::Main),
        
        // Pre-Combat Main Phase progression
        (Phase::PreCombatMain, Step::Main) => (Phase::Combat, Step::BeginningOfCombat),
        
        // Combat Phase progression
        (Phase::Combat, Step::BeginningOfCombat) => (Phase::Combat, Step::DeclareAttackers),
        (Phase::Combat, Step::DeclareAttackers) => (Phase::Combat, Step::DeclareBlockers),
        (Phase::Combat, Step::DeclareBlockers) => {
            // Check if first strike is needed
            if combat_has_first_strike() {
                (Phase::Combat, Step::FirstStrikeDamage)
            } else {
                (Phase::Combat, Step::CombatDamage)
            }
        },
        (Phase::Combat, Step::FirstStrikeDamage) => (Phase::Combat, Step::CombatDamage),
        (Phase::Combat, Step::CombatDamage) => (Phase::Combat, Step::EndOfCombat),
        (Phase::Combat, Step::EndOfCombat) => (Phase::PostCombatMain, Step::Main),
        
        // Post-Combat Main Phase progression
        (Phase::PostCombatMain, Step::Main) => (Phase::Ending, Step::End),
        
        // Ending Phase progression
        (Phase::Ending, Step::End) => (Phase::Ending, Step::Cleanup),
        (Phase::Ending, Step::Cleanup) => {
            // Move to next turn
            (Phase::Beginning, Step::Untap)
        },
        
        // Default case (should never happen)
        _ => (current_phase, current_step),
    }
}
}

Phase Entry Actions

Each phase and step has specific actions that occur when entering:

#![allow(unused)]
fn main() {
fn execute_phase_entry_actions(
    phase: Phase,
    step: Step,
    commands: &mut Commands,
    game_state: &GameState,
) {
    match (phase, step) {
        // Beginning Phase actions
        (Phase::Beginning, Step::Untap) => {
            // Untap all permanents controlled by active player
            commands.add(untap_permanents_command(game_state.active_player));
            
            // Handle "at beginning of untap step" triggers
            commands.add(check_beginning_of_step_triggers_command(phase, step));
        },
        (Phase::Beginning, Step::Upkeep) => {
            // Handle "at beginning of upkeep" triggers
            commands.add(check_beginning_of_step_triggers_command(phase, step));
            
            // Grant priority to active player
            commands.add(grant_priority_command(game_state.active_player));
        },
        (Phase::Beginning, Step::Draw) => {
            // Active player draws a card (except on first turn)
            if game_state.turn_number > 1 {
                commands.add(draw_card_command(game_state.active_player, 1));
            }
            
            // Handle "at beginning of draw step" triggers
            commands.add(check_beginning_of_step_triggers_command(phase, step));
            
            // Grant priority to active player
            commands.add(grant_priority_command(game_state.active_player));
        },
        
        // Main Phase actions
        (Phase::PreCombatMain, Step::Main) | (Phase::PostCombatMain, Step::Main) => {
            // Reset land plays for turn if entering first main phase
            if phase == Phase::PreCombatMain && game_state.current_phase != Phase::PreCombatMain {
                commands.add(reset_land_plays_command());
            }
            
            // Grant priority to active player
            commands.add(grant_priority_command(game_state.active_player));
        },
        
        // Combat Phase actions
        (Phase::Combat, _) => {
            // Handle specific combat step actions
            match step {
                Step::BeginningOfCombat => {
                    // Handle "at beginning of combat" triggers
                    commands.add(check_beginning_of_step_triggers_command(phase, step));
                },
                Step::DeclareAttackers => {
                    // Active player declares attackers
                    commands.add(declare_attackers_command());
                },
                Step::DeclareBlockers => {
                    // Defending players declare blockers
                    commands.add(declare_blockers_command());
                },
                Step::FirstStrikeDamage => {
                    // Assign and deal first strike damage
                    commands.add(assign_first_strike_damage_command());
                    commands.add(deal_combat_damage_command(true));
                },
                Step::CombatDamage => {
                    // Assign and deal regular combat damage
                    commands.add(assign_combat_damage_command());
                    commands.add(deal_combat_damage_command(false));
                },
                _ => {}
            }
            
            // Grant priority to active player (for all combat steps)
            commands.add(grant_priority_command(game_state.active_player));
        },
        
        // Ending Phase actions
        (Phase::Ending, Step::End) => {
            // Handle "at beginning of end step" triggers
            commands.add(check_beginning_of_step_triggers_command(phase, step));
            
            // Grant priority to active player
            commands.add(grant_priority_command(game_state.active_player));
        },
        (Phase::Ending, Step::Cleanup) => {
            // Discard to hand size
            commands.add(discard_to_hand_size_command(game_state.active_player));
            
            // Remove "until end of turn" effects
            commands.add(remove_until_end_of_turn_effects_command());
            
            // Clear damage from permanents
            commands.add(clear_damage_command());
            
            // No priority is granted in cleanup step unless a trigger occurs
        },
    }
}
}

Turn Advancement

When a turn ends, the game advances to the next player's turn:

#![allow(unused)]
fn main() {
fn advance_turn_system(
    mut commands: Commands,
    mut game_state: ResMut<GameState>,
    mut turn_events: EventWriter<TurnChangeEvent>,
) {
    // Only advance turn when transitioning from cleanup to untap
    if game_state.current_phase == Phase::Ending && 
       game_state.current_step == Step::Cleanup &&
       !game_state.transitioning_to_next_turn {
        
        // Mark that we're transitioning to next turn
        game_state.transitioning_to_next_turn = true;
        
        // Get next player
        let next_player = get_next_player(game_state.active_player, &game_state);
        
        // Send turn change event
        turn_events.send(TurnChangeEvent {
            previous_player: game_state.active_player,
            next_player,
            turn_number: game_state.turn_number + 1,
        });
        
        // Update game state
        game_state.active_player = next_player;
        game_state.turn_number += 1;
        
        // Reset turn-based tracking
        commands.add(reset_turn_tracking_command());
    }
}
}

Extra Turns

The system also supports extra turns, which are handled by modifying the turn order:

#![allow(unused)]
fn main() {
pub fn add_extra_turn(
    player: Entity,
    game_state: &mut GameState,
    extra_turn_events: &mut EventWriter<ExtraTurnEvent>,
) {
    // Add extra turn to the queue
    game_state.extra_turns.push(player);
    
    // Send event
    extra_turn_events.send(ExtraTurnEvent {
        player,
        source_turn: game_state.turn_number,
    });
}

fn determine_next_turn_player(game_state: &GameState) -> Entity {
    // Check if there are extra turns queued
    if !game_state.extra_turns.is_empty() {
        // Take the next extra turn
        return game_state.extra_turns[0];
    }
    
    // Otherwise, proceed to next player in turn order
    get_next_player(game_state.active_player, game_state)
}
}

Implementation Status

The turn structure implementation currently:

  • ✅ Handles all standard phases and steps
  • ✅ Implements proper phase transitions
  • ✅ Supports phase-specific actions
  • ✅ Manages turn advancement
  • ✅ Supports extra turns
  • 🔄 Implementing special turn modifications (e.g., additional combat phases)
  • 🔄 Supporting time counters and suspended cards

Next: Combat System

Game Zones

Overview

Zones are distinct areas where cards can exist during a game of Magic: The Gathering. This section documents the core implementation of game zones in Rummage, which serve as the foundation for all MTG formats.

Standard Game Zones

Magic: The Gathering has the following standard game zones:

  1. Library - The player's deck of cards, face down and in a randomized order
  2. Hand - Cards held by a player, visible only to that player (unless affected by card effects)
  3. Battlefield - Where permanents (lands, creatures, artifacts, enchantments, planeswalkers) exist in play
  4. Graveyard - Discard pile for destroyed, sacrificed, or discarded cards, face up
  5. Stack - Where spells and abilities wait to resolve
  6. Exile - Cards removed from the game, face up unless specified otherwise

Additionally, some formats introduce special zones:

  • Command Zone - Used primarily in Commander format for commanders and emblems

Core Zone Implementation

Each zone is implemented as an entity with components that track its contained cards. The zone system manages the movement of cards between zones according to the MTG rules.

Zone Management Components

#![allow(unused)]
fn main() {
#[derive(Resource)]
pub struct ZoneManager {
    // Maps player entities to their personal zones (library, hand, graveyard)
    pub player_zones: HashMap<Entity, PlayerZones>,
    
    // Shared zones (battlefield, stack, exile)
    pub battlefield: Entity,
    pub stack: Entity,
    pub exile: Entity,
    
    // Format-specific zones can be added by plugins
    pub command_zone: Option<Entity>,
}

#[derive(Component)]
pub struct Zone {
    pub zone_type: ZoneType,
    pub owner: Option<Entity>, // None for shared zones
    pub cards: Vec<Entity>,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ZoneType {
    Library,
    Hand,
    Battlefield,
    Graveyard,
    Stack,
    Exile,
    Command,
}

pub struct PlayerZones {
    pub library: Entity,
    pub hand: Entity,
    pub graveyard: Entity,
}
}

Zone Transitions

When a card moves from one zone to another, a zone transition occurs. These transitions are managed by the zone system and can trigger abilities based on the zones involved.

Zone Transition Events

#![allow(unused)]
fn main() {
#[derive(Event)]
pub struct ZoneTransitionEvent {
    pub card: Entity,
    pub from_zone: Entity,
    pub to_zone: Entity,
    pub reason: ZoneTransitionReason,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ZoneTransitionReason {
    Cast,
    Resolve,
    Destroy,
    Sacrifice,
    Discard,
    Draw,
    ReturnToHand,
    PutIntoPlay,
    Exile,
    ShuffleIntoLibrary,
    // ...and more
}
}

Zone Transition System

#![allow(unused)]
fn main() {
pub fn handle_zone_transitions(
    mut commands: Commands,
    mut zone_events: EventReader<ZoneTransitionEvent>,
    mut zones: Query<&mut Zone>,
    mut triggered_abilities: EventWriter<TriggeredAbilityEvent>,
    cards: Query<&Card>,
) {
    for event in zone_events.iter() {
        // Remove card from source zone
        if let Ok(mut source_zone) = zones.get_mut(event.from_zone) {
            if let Some(index) = source_zone.cards.iter().position(|&c| c == event.card) {
                source_zone.cards.remove(index);
            }
        }
        
        // Add card to destination zone
        if let Ok(mut dest_zone) = zones.get_mut(event.to_zone) {
            dest_zone.cards.push(event.card);
        }
        
        // Check for triggered abilities based on zone transitions
        if let Ok(card) = cards.get(event.card) {
            // Process any "when this card enters/leaves a zone" abilities
            // ...
        }
    }
}
}

Zone-Specific Rules

Each zone has specific rules that govern how cards interact within and with that zone:

Library

  • Cards are face down and in random order
  • Players draw from the top
  • Running out of cards to draw results in a loss

Hand

  • Cards are visible only to their owner
  • Hand size is normally limited to 7 at end of turn
  • Players discard down to maximum hand size during cleanup

Battlefield

  • Only permanents can exist on the battlefield
  • Cards on the battlefield are affected by "summoning sickness"
  • Most abilities can only be activated from the battlefield

Graveyard

  • Cards are face up and can be seen by all players
  • Order of cards matters for some effects
  • Some abilities function from the graveyard

Stack

  • Spells and abilities go on the stack before resolving
  • The stack resolves in "last in, first out" order
  • All players get priority between stack resolutions

Exile

  • Cards are face up by default
  • Exiled cards are generally inaccessible
  • Some effects allow interaction with exiled cards

Format Extensions

Different formats may extend or modify the basic zone system:

  • Commander Format: Adds the Command Zone for commanders
  • Planechase: Adds a plane card zone
  • Archenemy: Adds a scheme card zone

For format-specific zone mechanics like the Command Zone in Commander, see the respective format documentation.


Next: Zone Transitions

Stack and Priority System

Overview

The stack is a fundamental mechanic in Magic: The Gathering, representing the order in which spells and abilities resolve. This section documents the core implementation of the stack and priority system in Rummage.

The Stack

The stack is a last-in, first-out (LIFO) data structure that determines the order in which spells and abilities resolve during gameplay.

Stack Implementation

#![allow(unused)]
fn main() {
#[derive(Resource)]
pub struct Stack {
    // The stack itself - a list of stack items in order
    pub items: Vec<StackItem>,
    
    // The currently resolving stack item, if any
    pub currently_resolving: Option<StackItem>,
    
    // Temporary storage for abilities that trigger during resolution
    pub pending_triggers: Vec<TriggeredAbility>,
}

#[derive(Clone, Debug)]
pub struct StackItem {
    pub id: Entity,
    pub item_type: StackItemType,
    pub controller: Entity,
    pub source: Entity,
    pub targets: Vec<StackTarget>,
    pub effects: Vec<Effect>,
}

#[derive(Clone, Debug, PartialEq, Eq)]
pub enum StackItemType {
    Spell(CardType),
    Ability(AbilityType),
}

#[derive(Clone, Debug, PartialEq, Eq)]
pub enum AbilityType {
    Activated,
    Triggered,
    Static, // Static abilities generally don't use the stack but are tracked here for targeting purposes
    Mana,   // Mana abilities don't use the stack but may be tracked here
}
}

Stack Order and Resolution

The stack follows these core principles:

  1. The active player gets priority first in each phase and step
  2. When a player has priority, they can cast spells or activate abilities
  3. Each spell or ability goes on top of the stack
  4. After a spell or ability is put on the stack, all players get priority again
  5. When all players pass priority in succession, the top item of the stack resolves
  6. After resolution, the active player gets priority again

Stack Resolution System

#![allow(unused)]
fn main() {
pub fn resolve_stack(
    mut commands: Commands,
    mut stack: ResMut<Stack>,
    mut game_state: ResMut<GameState>,
    mut zone_transitions: EventWriter<ZoneTransitionEvent>,
    mut effect_events: EventWriter<EffectEvent>,
) {
    // Check if there's something to resolve
    if stack.items.is_empty() {
        return;
    }
    
    // Get the top item
    let item = stack.items.pop().unwrap();
    stack.currently_resolving = Some(item.clone());
    
    // Process based on item type
    match item.item_type {
        StackItemType::Spell(card_type) => {
            // Process spell resolution
            // Move card to appropriate zone (battlefield for permanents, graveyard for others)
            // ...
        },
        StackItemType::Ability(ability_type) => {
            // Process ability resolution
            // ...
        }
    }
    
    // Process effects
    for effect in item.effects.iter() {
        effect_events.send(EffectEvent {
            effect: effect.clone(),
            source: item.source,
            controller: item.controller,
            targets: item.targets.clone(),
        });
    }
    
    // Clear currently resolving
    stack.currently_resolving = None;
    
    // Check for triggered abilities that happened during resolution
    // ...
}
}

Priority System

The priority system determines which player can take actions at any given time. Priority passes in turn order, starting with the active player.

Priority Implementation

#![allow(unused)]
fn main() {
#[derive(Resource)]
pub struct PrioritySystem {
    pub current_player: Entity,
    pub passed_players: HashSet<Entity>,
    pub active: bool,
}

#[derive(Event)]
pub struct PriorityEvent {
    pub player: Entity,
    pub action: PriorityAction,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PriorityAction {
    Receive,
    Pass,
    TakeAction,
}
}

Priority Passing System

#![allow(unused)]
fn main() {
pub fn handle_priority(
    mut commands: Commands,
    mut priority: ResMut<PrioritySystem>,
    mut priority_events: EventReader<PriorityEvent>,
    game_state: Res<GameState>,
    stack: Res<Stack>,
) {
    for event in priority_events.iter() {
        match event.action {
            PriorityAction::Pass => {
                // Record that this player passed
                priority.passed_players.insert(event.player);
                
                // Determine next player
                let next_player = get_next_player_in_turn_order(event.player, &game_state);
                
                // Check if all players have passed
                if priority.passed_players.len() == game_state.player_order.len() {
                    // Resolve top of stack or advance phase
                    if !stack.items.is_empty() {
                        // Resolve top of stack
                        commands.add(resolve_stack_command());
                    } else {
                        // Advance to next phase
                        commands.add(advance_phase_command());
                    }
                    
                    // Reset priority system
                    priority.passed_players.clear();
                    priority.current_player = game_state.active_player;
                } else {
                    // Pass priority to next player
                    priority.current_player = next_player;
                }
            },
            PriorityAction::TakeAction => {
                // Clear passed players since someone took an action
                priority.passed_players.clear();
                
                // After an action, active player gets priority
                priority.current_player = game_state.active_player;
            },
            // Other priority actions...
        }
    }
}
}

Special Rules

Mana Abilities

Mana abilities don't use the stack and resolve immediately, meaning they cannot be responded to. This is handled separately from the regular stack system.

Split Second

Some cards have the "Split Second" ability, which means that while they're on the stack, players can't cast spells or activate abilities that aren't mana abilities.

Interrupts and Special Timing

Certain effects can interrupt the normal flow of gameplay, such as:

  • Replacement effects
  • Prevention effects
  • State-based actions
  • Special actions that don't use the stack

Format Extensions

Different formats may extend or modify the basic stack system:

  • Commander Format: May have format-specific timing rules
  • Multiplayer Formats: Must handle priority passing among multiple players

For format-specific stack and priority mechanics, see the respective format documentation.


Next: Stack Resolution

Stack Resolution

This document details the implementation of the stack resolution process in Rummage, explaining how spells and abilities on the stack are processed and resolved.

Resolution Overview

Stack resolution follows the Last-In-First-Out (LIFO) principle. When all players pass priority in succession without taking any actions, the top item on the stack resolves. This process requires careful handling of various card effects, targets, and game state changes.

Resolution Process

1. Prepare for Resolution

The system begins by taking the top item off the stack and preparing it for resolution:

#![allow(unused)]
fn main() {
fn prepare_for_resolution(stack: &mut Stack, world: &mut World) -> Option<StackItem> {
    if stack.items.is_empty() {
        return None;
    }
    
    // Remove the top item from the stack
    let item = stack.items.pop().unwrap();
    
    // Store the currently resolving item for reference
    stack.currently_resolving = Some(item.clone());
    
    Some(item)
}
}

2. Check Targets

Before resolution, the system verifies that all targets are still legal:

#![allow(unused)]
fn main() {
fn verify_targets(item: &StackItem, world: &World) -> bool {
    let mut all_targets_legal = true;
    
    for target in &item.targets {
        if !is_legal_target(target, item, world) {
            all_targets_legal = false;
            break;
        }
    }
    
    all_targets_legal
}
}

If a spell or ability has no legal targets remaining, it is countered by game rules:

#![allow(unused)]
fn main() {
if !verify_targets(&item, world) {
    if item.item_type.is_spell() {
        // Counter the spell
        events.send(SpellCounteredEvent {
            spell_id: item.id,
            countered_by: CounterReason::IllegalTargets,
        });
        
        // Move spell card to graveyard
        zone_transitions.send(ZoneTransitionEvent {
            entity: item.source,
            from: Zone::Stack,
            to: Zone::Graveyard,
            cause: ZoneTransitionCause::Countered,
        });
    } else {
        // Counter the ability
        events.send(AbilityCounteredEvent {
            ability_id: item.id,
            countered_by: CounterReason::IllegalTargets,
        });
    }
    
    return;
}
}

3. Process Spell Resolution

For spell resolution, the system handles different card types appropriately:

#![allow(unused)]
fn main() {
fn resolve_spell(item: &StackItem, world: &mut World, events: &mut EventWriter<ZoneTransitionEvent>) {
    match item.item_type {
        StackItemType::Spell(CardType::Creature) |
        StackItemType::Spell(CardType::Artifact) |
        StackItemType::Spell(CardType::Enchantment) |
        StackItemType::Spell(CardType::Planeswalker) |
        StackItemType::Spell(CardType::Land) => {
            // Permanent spells - move to battlefield
            events.send(ZoneTransitionEvent {
                entity: item.source,
                from: Zone::Stack,
                to: Zone::Battlefield,
                cause: ZoneTransitionCause::Resolved,
            });
        },
        StackItemType::Spell(CardType::Instant) |
        StackItemType::Spell(CardType::Sorcery) => {
            // Non-permanent spells - move to graveyard
            events.send(ZoneTransitionEvent {
                entity: item.source,
                from: Zone::Stack,
                to: Zone::Graveyard,
                cause: ZoneTransitionCause::Resolved,
            });
        },
        _ => {} // Handle other card types
    }
}
}

4. Process Ability Resolution

For ability resolution, the system processes the effects without zone transitions:

#![allow(unused)]
fn main() {
fn resolve_ability(item: &StackItem, world: &mut World) {
    // Abilities don't change zones, only their effects are processed
    match item.item_type {
        StackItemType::Ability(AbilityType::Activated) |
        StackItemType::Ability(AbilityType::Triggered) => {
            // Process ability effects
        },
        _ => {} // Handle other ability types
    }
}
}

5. Apply Effects

The system processes each effect of the resolving item:

#![allow(unused)]
fn main() {
fn apply_effects(
    item: &StackItem, 
    world: &mut World,
    effect_events: &mut EventWriter<EffectEvent>
) {
    for effect in &item.effects {
        effect_events.send(EffectEvent {
            effect: effect.clone(),
            source: item.source,
            controller: item.controller,
            targets: item.targets.clone(),
        });
    }
}
}

6. Post-Resolution Cleanup

After resolution, the system clears the currently resolving item and checks for triggered abilities:

#![allow(unused)]
fn main() {
fn post_resolution_cleanup(stack: &mut Stack, world: &mut World) {
    // Clear the currently resolving item
    stack.currently_resolving = None;
    
    // Process pending triggers that occurred during resolution
    for trigger in stack.pending_triggers.drain(..) {
        world.spawn_trigger(trigger);
    }
    
    // Check state-based actions
    check_state_based_actions(world);
    
    // Return priority to active player
    set_priority(world.resource::<GameState>().active_player, world);
}
}

Special Resolution Cases

Countering Spells and Abilities

When a spell or ability is countered, it never resolves:

#![allow(unused)]
fn main() {
pub fn counter_spell_system(
    mut counter_events: EventReader<CounterSpellEvent>,
    mut stack: ResMut<Stack>,
    mut zone_transitions: EventWriter<ZoneTransitionEvent>,
) {
    for event in counter_events.iter() {
        // Find the spell on the stack
        if let Some(index) = stack.items.iter().position(|item| item.id == event.target) {
            // Remove it from the stack
            let item = stack.items.remove(index);
            
            // Move the card to graveyard
            zone_transitions.send(ZoneTransitionEvent {
                entity: item.source,
                from: Zone::Stack,
                to: Zone::Graveyard,
                cause: ZoneTransitionCause::Countered,
            });
        }
    }
}
}

Split Second

When resolving items with Split Second, special handling prevents responses:

#![allow(unused)]
fn main() {
pub fn check_split_second(stack: &Stack) -> bool {
    stack.items.iter().any(|item| {
        if let StackItemType::Spell(card_type) = &item.item_type {
            // Check if any spell on the stack has split second
            let has_split_second = world
                .get::<Card>(item.source)
                .map(|card| card.has_ability(Ability::SplitSecond))
                .unwrap_or(false);
                
            return has_split_second;
        }
        false
    })
}
}

Integration with Other Systems

The stack resolution process integrates with:

  1. Zone System: Moving cards between zones after resolution
  2. Effect System: Processing the effects of spells and abilities
  3. Targeting System: Verifying target legality
  4. Trigger System: Handling triggers that occur during resolution
  5. State-Based Action System: Checking state-based actions after resolution

Implementation Status

The stack resolution implementation currently:

  • ✅ Handles basic spell and ability resolution
  • ✅ Processes spell types correctly (permanent vs. non-permanent)
  • ✅ Validates targets before resolution
  • ✅ Implements spell countering
  • ✅ Supports triggered abilities that trigger during resolution
  • 🔄 Handling complex resolution effects (choices, conditions, etc.)
  • 🔄 Implementing special cases like Split Second

Next: Priority System

State-Based Actions in MTG

Overview

State-Based Actions (SBAs) are one of the foundational mechanisms of Magic: The Gathering's rules system. They serve as automatic checks that the game performs regularly to ensure game rules are properly enforced without requiring explicit player actions. These checks occur whenever a player would receive priority and before any player actually receives priority.

Core Functionality

State-based actions handle many crucial game state validations:

  • Player loss conditions:

    • A player with 0 or less life loses the game
    • A player attempting to draw from an empty library loses the game
    • A player with 10 or more poison counters loses the game (in formats where poison counters are relevant)
  • Creature conditions:

    • Creatures with toughness 0 or less are put into their owner's graveyard
    • Creatures with damage marked on them greater than or equal to their toughness are destroyed
    • Creatures that have been dealt damage by a source with deathtouch are destroyed
  • Permanent-specific rules:

    • Planeswalker with no loyalty counters is put into its owner's graveyard
    • Auras that aren't attached to anything or attached illegally are put into their owner's graveyard
    • Equipment or Fortifications that are attached illegally become unattached
    • Legendary permanents with the same name are put into their owner's graveyard except the most recently played one
  • Token and counter rules:

    • A token that has left the battlefield ceases to exist
    • If a permanent has both +1/+1 and -1/-1 counters on it, they cancel out in pairs

Implementation

The state-based actions system is implemented as a collection of systems that run between steps and phases:

#![allow(unused)]
fn main() {
#[derive(Resource)]
pub struct StateBasedActionSystem {
    // Configuration for SBA processing
    pub enabled: bool,
    pub last_check_time: Duration,
    pub check_frequency: Duration,
    
    // Tracking for specific SBAs
    pub pending_legendary_checks: Vec<Entity>,
    pub pending_destruction: Vec<Entity>,
    pub pending_life_checks: Vec<Entity>,
}

// Systems that implement specific checks
pub fn check_player_loss_conditions(/* ... */);
pub fn check_creature_destruction(/* ... */);
pub fn check_attachment_validity(/* ... */);
pub fn check_legendary_rule(/* ... */);
pub fn check_token_existence(/* ... */);
pub fn check_counter_interactions(/* ... */);
}

Application Order

State-based actions are applied in a specific order, but all applicable state-based actions are performed simultaneously as a single event. If performing state-based actions creates new conditions for state-based actions, those new state-based actions will be checked in the next round of checks.

This is especially important for situations like:

  • A player at 0 life with a creature dying simultaneously
  • Multiple legendary permanents entering the battlefield at the same time
  • Creatures with damage marked receiving -X/-X effects

Multiplayer Considerations

In multiplayer games:

  • State-based actions are checked simultaneously for all players and permanents
  • When a player leaves the game, all cards they own leave the game
  • Objects controlled by a player who left the game are exiled, unless they're controlled by a different player's ongoing effect

Optimization in Rummage

The SBA system in Rummage is optimized to:

  • Only check relevant game objects (using spatial partitioning where appropriate)
  • Use efficient data structures to track pending actions
  • Batch similar checks where possible
  • Cache results when appropriate
  • Minimize performance impact during complex game states
  • Turn Structure: When state-based actions are checked during the turn
  • Zones: How state-based actions interact with game zones
  • Combat: Combat-specific state-based actions

Combat System

Overview

The Combat system is a fundamental part of Magic: The Gathering, implemented in Rummage according to the comprehensive rules. This section documents the core combat mechanics that apply to all formats.

Combat Phases

A combat phase consists of the following steps in order:

  1. Beginning of Combat Step - The last chance for players to cast spells or activate abilities before attackers are declared
  2. Declare Attackers Step - The active player chooses which creatures will attack and which opponents or planeswalkers they will attack
  3. Declare Blockers Step - Each defending player chooses which creatures will block and which attacking creatures they will block
  4. Combat Damage Step - Combat damage is assigned and dealt (with separate first strike and regular damage steps if needed)
  5. End of Combat Step - The last chance for players to cast spells or activate abilities before the combat phase ends

Core Combat Mechanics

Attacking

  • Only untapped creatures can be declared as attackers
  • Creatures can't attack their controller
  • Creatures can attack players or planeswalkers (unless modified by card effects)
  • Creatures with Defender can't attack
  • Creatures with Summoning Sickness (entered battlefield this turn) can't attack unless they have Haste

Blocking

  • Tapped creatures can't block
  • Each blocking creature can only block one attacker (unless modified by card effects)
  • Multiple creatures can block a single attacker
  • Creatures can only block attackers that are attacking their controller or planeswalkers controlled by their controller

Combat Damage

  • Attacking creatures deal damage equal to their power
  • Blocking creatures deal damage equal to their power
  • If multiple creatures block a single attacker, the attacker's controller assigns the damage among them
  • Combat damage is dealt simultaneously (unless first strike or double strike is involved)
  • Combat damage to players causes life loss
  • Combat damage to planeswalkers removes loyalty counters
  • Combat damage to creatures causes damage marked on them

Special Combat Abilities

  • First Strike: Creatures with first strike deal combat damage before creatures without first strike
  • Double Strike: Creatures with double strike deal damage in both the first strike and regular damage steps
  • Trample: Excess combat damage can be assigned to the defending player or planeswalker
  • Vigilance: Creatures with vigilance don't tap when attacking
  • Deathtouch: Any amount of damage from a creature with deathtouch is enough to destroy the damaged creature
  • Lifelink: Damage dealt by a creature with lifelink causes its controller to gain that much life

Implementation Details

The combat system is implemented through a series of components and systems that manage the state of creatures in combat, process combat events, and enforce the rules of combat.

Extensions for Formats

Different formats may extend or modify these core combat mechanics:

  • Commander Format: Adds commander damage tracking
  • Two-Headed Giant: Modifies how creatures can attack and block
  • Planechase: May add special combat rules based on the current plane

For format-specific extensions like Commander's combat rules, see the respective format documentation.


Combat Documentation

Combat Phases

Overview

The combat phase in Magic: The Gathering is divided into five distinct steps, each with specific rules and opportunities for player interaction. This document details how these steps are implemented in Rummage's core engine.

Beginning of Combat Step

The Beginning of Combat step marks the start of the combat phase. During this step:

  1. "At the beginning of combat" triggered abilities go on the stack
  2. Players receive priority, starting with the active player
  3. This is the last opportunity to use effects that would prevent creatures from attacking

Implementation

#![allow(unused)]
fn main() {
pub fn beginning_of_combat_system(
    mut commands: Commands,
    game_state: Res<GameState>,
    mut phase_events: EventWriter<BeginningOfCombatEvent>,
    mut triggered_abilities: EventWriter<TriggeredAbilityEvent>,
) {
    // Generate beginning of combat event
    phase_events.send(BeginningOfCombatEvent {
        active_player: game_state.active_player,
    });
    
    // Check for triggered abilities that trigger at beginning of combat
    // Add them to the stack
    // ...
}
}

Declare Attackers Step

During the Declare Attackers step:

  1. The active player declares attackers
  2. Creatures attack as a group
  3. The active player taps attacking creatures (unless they have vigilance)
  4. "Whenever a creature attacks" triggered abilities go on the stack
  5. Players receive priority, starting with the active player

Attacking Rules

  • Only untapped creatures controlled by the active player can be declared as attackers
  • Each attacking creature must attack either an opponent or a planeswalker an opponent controls
  • Creatures with summoning sickness can't attack unless they have haste
  • Attacking doesn't use the stack and can't be responded to directly

Implementation

#![allow(unused)]
fn main() {
pub fn declare_attackers_system(
    mut commands: Commands,
    mut game_state: ResMut<GameState>,
    mut attack_events: EventReader<DeclareAttackEvent>,
    mut creatures: Query<(Entity, &mut Creature, &Controller)>,
    mut triggered_abilities: EventWriter<TriggeredAbilityEvent>,
) {
    // Process attack declarations
    for attack_event in attack_events.iter() {
        // Validate attackers (untapped, controlled by active player, etc.)
        // Record which creatures are attacking and what they're attacking
        // Tap attacking creatures without vigilance
        // ...
    }
    
    // Generate triggered abilities for "whenever a creature attacks"
    // ...
}
}

Declare Blockers Step

During the Declare Blockers step:

  1. The defending player(s) declare blockers
  2. Each blocking creature must block exactly one attacking creature
  3. "Whenever a creature blocks" triggered abilities go on the stack
  4. The active player declares the damage assignment order for creatures blocked by multiple creatures
  5. Players receive priority, starting with the active player

Blocking Rules

  • Only untapped creatures can block
  • Each creature can block only one attacker (unless it has special abilities)
  • Multiple creatures can block a single attacker
  • Blocking doesn't use the stack and can't be responded to directly

Implementation

#![allow(unused)]
fn main() {
pub fn declare_blockers_system(
    mut commands: Commands,
    mut game_state: ResMut<GameState>,
    mut block_events: EventReader<DeclareBlockEvent>,
    mut creatures: Query<(Entity, &Creature, &Controller)>,
    mut triggered_abilities: EventWriter<TriggeredAbilityEvent>,
) {
    // Process block declarations
    for block_event in block_events.iter() {
        // Validate blockers (untapped, etc.)
        // Record which creatures are blocking and what they're blocking
        // ...
    }
    
    // Generate triggered abilities for "whenever a creature blocks" or "becomes blocked"
    // ...
    
    // Determine damage assignment order for multiple blockers
    // ...
}
}

Combat Damage Step

During the Combat Damage step:

  1. If any creatures have first strike or double strike, a separate First Strike Combat Damage step occurs first
  2. The active player assigns combat damage from their attacking creatures
  3. The defending player(s) assign combat damage from their blocking creatures
  4. All combat damage is dealt simultaneously
  5. Players receive priority, starting with the active player

Damage Assignment Rules

  • Each attacking creature assigns damage equal to its power
  • Each blocking creature assigns damage equal to its power
  • Blocked creatures assign their damage to the blocking creatures
  • Unblocked creatures assign their damage to the player or planeswalker they're attacking
  • If multiple creatures block an attacker, the attacker's controller decides how to distribute the damage

Implementation

#![allow(unused)]
fn main() {
pub fn combat_damage_system(
    mut commands: Commands,
    game_state: Res<GameState>,
    combat_state: Res<CombatState>,
    mut creatures: Query<(Entity, &Creature, &mut Health)>,
    mut players: Query<(Entity, &Player, &mut Life)>,
    mut planeswalkers: Query<(Entity, &Planeswalker, &mut Loyalty)>,
) {
    // Handle first strike damage if needed
    // ...
    
    // Assign and deal combat damage
    for (attacker, targets) in combat_state.attackers.iter() {
        // Calculate damage amount
        // Apply damage to appropriate targets
        // Handle special abilities (deathtouch, lifelink, etc.)
        // ...
    }
    
    // Check for state-based actions after damage
    // ...
}
}

End of Combat Step

During the End of Combat step:

  1. "At end of combat" triggered abilities go on the stack
  2. Players receive priority, starting with the active player
  3. After this step, all creatures and planeswalkers are removed from combat

Implementation

#![allow(unused)]
fn main() {
pub fn end_of_combat_system(
    mut commands: Commands,
    game_state: Res<GameState>,
    mut combat_state: ResMut<CombatState>,
    mut phase_events: EventWriter<EndOfCombatEvent>,
    mut triggered_abilities: EventWriter<TriggeredAbilityEvent>,
) {
    // Generate end of combat event
    phase_events.send(EndOfCombatEvent {
        active_player: game_state.active_player,
    });
    
    // Check for triggered abilities that trigger at end of combat
    // ...
    
    // Remove all creatures from combat
    combat_state.attackers.clear();
    combat_state.blockers.clear();
    // ...
}
}

Format-Specific Extensions

Different MTG formats may extend these basic combat phases with additional rules:

  • Commander: Tracks commander damage separately
  • Two-Headed Giant: Modifies how combat damage is dealt to players
  • Multiplayer Formats: Have special rules for attacking multiple opponents

For details on format-specific combat mechanics, see the respective format documentation.


Next: First Strike and Double Strike

First Strike and Double Strike

Overview

First Strike and Double Strike are two of the most important keyword abilities in Magic: The Gathering that modify how creatures deal combat damage. They create a special combat damage step that occurs before the regular combat damage step.

First Strike

First Strike allows creatures to deal combat damage before creatures without First Strike.

Rules

  • Creatures with First Strike deal damage in the "first strike combat damage step" which happens before the regular combat damage step
  • Creatures without First Strike or Double Strike don't deal or receive combat damage in this step
  • If no creatures with First Strike or Double Strike are involved in combat, this step is skipped entirely
  • First Strike doesn't provide any additional advantage in creature-to-creature combat outside of combat damage timing

Implementation

#![allow(unused)]
fn main() {
#[derive(Component)]
pub struct FirstStrike;

pub fn handle_first_strike_damage(
    combat_state: Res<CombatState>,
    creatures: Query<(Entity, &Creature, &Attack, &BlockedBy, Option<&FirstStrike>, Option<&DoubleStrike>)>,
    mut health: Query<&mut Health>,
    mut damage_events: EventWriter<DamageEvent>,
) {
    // Identify creatures with First Strike or Double Strike
    let first_strikers = creatures.iter()
        .filter(|(_, _, _, _, first_strike, double_strike)| 
            first_strike.is_some() || double_strike.is_some());
            
    // Process First Strike damage
    for (entity, creature, attack, blocked_by, _, _) in first_strikers {
        // Calculate and assign damage
        // ...
    }
}
}

Double Strike

Double Strike combines the benefits of First Strike with the ability to deal damage again in the regular combat damage step.

Rules

  • Creatures with Double Strike deal combat damage in both the first strike combat damage step AND the regular combat damage step
  • This effectively allows them to deal twice their power in damage during combat
  • If a Double Strike creature is removed from combat after the first strike step (e.g., by being destroyed), it won't deal damage in the regular step
  • Double Strike doesn't increase the amount of damage dealt in each individual step - it allows the creature to deal damage in both steps

Implementation

#![allow(unused)]
fn main() {
#[derive(Component)]
pub struct DoubleStrike;

pub fn handle_regular_combat_damage(
    combat_state: Res<CombatState>,
    creatures: Query<(Entity, &Creature, &Attack, &BlockedBy, Option<&DoubleStrike>)>,
    mut health: Query<&mut Health>,
    mut damage_events: EventWriter<DamageEvent>,
) {
    // Process Regular Combat Damage
    for (entity, creature, attack, blocked_by, _) in creatures.iter() {
        // All creatures without First Strike always deal damage here
        // Creatures with Double Strike also deal damage here
        // ...
    }
}
}

Interactions with Other Abilities

First Strike and Double Strike interact with other combat abilities in specific ways:

  • Deathtouch: A creature with both First Strike/Double Strike and Deathtouch can destroy blocking/blocked creatures before they deal damage
  • Lifelink: A creature with both Double Strike and Lifelink will cause its controller to gain life twice
  • Trample: A creature with both First Strike/Double Strike and Trample can deal excess damage to players in both damage steps

Example Combat Scenarios

First Strike vs. Non-First Strike

When a 2/2 creature with First Strike blocks or is blocked by a 2/2 creature without First Strike:

  1. First Strike creature deals 2 damage in the first strike damage step
  2. The other creature is destroyed before it can deal damage in the regular damage step

Double Strike vs. Regular Creature

When a 2/2 creature with Double Strike blocks or is blocked by a 3/3 creature without First Strike:

  1. Double Strike creature deals 2 damage in the first strike damage step
  2. The 3/3 creature survives with 1 toughness remaining
  3. Both creatures deal damage in the regular damage step (2 more from Double Strike, 3 from the regular creature)
  4. Both creatures are destroyed

Combat Damage Calculation

Overview

Combat damage calculation is a critical aspect of the Magic: The Gathering combat system. This document explains how damage is calculated, assigned, and applied during combat in Rummage's implementation.

Damage Assignment

Basic Rules

  1. Each attacking or blocking creature assigns combat damage equal to its power
  2. Attacking creatures that aren't blocked assign their damage to the player or planeswalker they're attacking
  3. Blocking creatures assign their damage to the creature they're blocking
  4. Blocked creatures assign their damage to the creatures blocking them

Multiple Blockers

When multiple creatures block a single attacker:

  1. The attacking player puts the blocking creatures in a damage assignment order
  2. The attacker must assign at least lethal damage to each creature in the order before assigning damage to the next creature
  3. "Lethal damage" is damage equal to the creature's toughness minus damage already marked on it
#![allow(unused)]
fn main() {
pub fn determine_damage_assignment_order(
    mut commands: Commands,
    mut combat_state: ResMut<CombatState>,
    blocker_groups: Query<(&Creature, &Health, &Blocking)>,
) {
    for (attacker_entity, blockers) in combat_state.blockers_per_attacker.iter() {
        if blockers.len() > 1 {
            // Request damage assignment order from attacking player
            // Store the order in the combat state
            // ...
        }
    }
}
}

Damage Application

Once damage is assigned, it is applied simultaneously:

  1. Damage to creatures is marked on them but doesn't immediately reduce toughness
  2. Damage to players reduces their life total
  3. Damage to planeswalkers removes loyalty counters
  4. Special abilities like deathtouch and lifelink take effect at this time
#![allow(unused)]
fn main() {
pub fn apply_combat_damage(
    combat_state: Res<CombatState>,
    mut creatures: Query<(Entity, &Creature, &mut Health)>,
    mut players: Query<(Entity, &Player, &mut Life)>,
    mut planeswalkers: Query<(Entity, &Planeswalker, &mut Loyalty)>,
    mut damage_events: EventWriter<DamageEvent>,
) {
    // Calculate and apply damage from all sources
    // ...
    
    // For creatures
    for (target, damage_amount) in creature_damage.iter() {
        if let Ok((_, _, mut health)) = creatures.get_mut(*target) {
            health.marked_damage += *damage_amount;
            // Generate damage event
            damage_events.send(DamageEvent {
                source: source_entity,
                target: *target,
                amount: *damage_amount,
                is_combat_damage: true,
            });
        }
    }
    
    // For players and planeswalkers (similar logic)
    // ...
}
}

Special Damage Considerations

Deathtouch

Deathtouch modifies damage assignment rules:

  • Any amount of damage from a source with deathtouch is considered lethal damage
  • When assigning damage from an attacker with deathtouch to multiple blockers, only 1 damage needs to be assigned to each creature before moving to the next

Trample

Trample allows excess damage to be assigned to the player or planeswalker:

  • The attacker must still assign lethal damage to all blockers
  • Any remaining damage can be assigned to the defending player or planeswalker
  • If all blocking creatures are removed from combat, all damage is assigned to the player or planeswalker
#![allow(unused)]
fn main() {
pub fn handle_trample_damage(
    combat_state: Res<CombatState>,
    creatures: Query<(Entity, &Creature, &Attack, Option<&Trample>)>,
    blockers: Query<(Entity, &Creature, &Health, &Blocking)>,
    mut player_damage: ResMut<HashMap<Entity, u32>>,
) {
    for (attacker, creature, attack, trample) in creatures.iter() {
        if trample.is_some() {
            // Calculate how much damage is needed for blockers
            // Assign excess to the player or planeswalker
            // ...
        }
    }
}
}

Protection and Prevention

Some effects modify how damage is dealt or received:

  • Protection prevents damage from sources with specific characteristics
  • Prevention effects can replace some or all damage that would be dealt
  • These effects are applied during damage calculation, before damage is actually dealt

Damage Resolution

After combat damage is dealt, the game checks for state-based actions:

  1. Creatures with lethal damage are destroyed
  2. Players with 0 or less life lose the game
  3. Planeswalkers with 0 loyalty counters are put into their owner's graveyard
#![allow(unused)]
fn main() {
pub fn check_combat_damage_results(
    creatures: Query<(Entity, &Creature, &Health)>,
    players: Query<(Entity, &Player, &Life)>,
    planeswalkers: Query<(Entity, &Planeswalker, &Loyalty)>,
    mut destroy_events: EventWriter<DestroyPermanentEvent>,
    mut player_loss_events: EventWriter<PlayerLossEvent>,
) {
    // Check for destroyed creatures
    for (entity, _, health) in creatures.iter() {
        if health.marked_damage >= health.toughness {
            destroy_events.send(DestroyPermanentEvent {
                entity: entity,
                reason: DestructionReason::LethalDamage,
            });
        }
    }
    
    // Check for player losses and planeswalker destruction
    // ...
}
}

Abilities

This document outlines the different types of abilities in Magic: The Gathering as implemented in Rummage.

Types of Abilities

Magic: The Gathering has three main types of abilities:

  1. Activated Abilities: Abilities a player can activate by paying a cost
  2. Triggered Abilities: Abilities that trigger automatically when a specific event occurs
  3. Static Abilities: Abilities that modify the game rules or create continuous effects

Activated Abilities

Activated abilities are written in the format: "Cost: Effect." Examples include:

  • "{T}: Add {G}." (A basic Forest's mana ability)
  • "{2}{W}, {T}: Create a 1/1 white Soldier creature token."
  • "{B}, Pay 2 life: Draw a card."

Implementation

In Rummage, activated abilities are implemented as follows:

#![allow(unused)]
fn main() {
pub struct ActivatedAbility {
    pub activation_cost: Cost,
    pub effect: AbilityEffect,
    pub timing_restriction: Option<TimingRestriction>,
    pub target_requirement: Option<TargetRequirement>,
}

pub enum Cost {
    Mana(ManaCost),
    Tap,
    PayLife(u32),
    SacrificeThis,
    SacrificePermanent(PermanentType),
    Discard(DiscardRequirement),
    Exile(ExileRequirement),
    Multiple(Vec<Cost>),
    // Other cost types...
}
}

Mana Abilities

A special subcategory of activated abilities, mana abilities:

  • Produce mana
  • Don't target
  • Don't use the stack
  • Resolve immediately

Triggered Abilities

Triggered abilities use the words "when," "whenever," or "at." Examples include:

  • "When this creature enters the battlefield, draw a card."
  • "Whenever a creature dies, gain 1 life."
  • "At the beginning of your upkeep, draw a card."

Implementation

In Rummage, triggered abilities are implemented with:

#![allow(unused)]
fn main() {
pub struct TriggeredAbility {
    pub trigger_condition: TriggerCondition,
    pub effect: AbilityEffect,
    pub target_requirement: Option<TargetRequirement>,
}

pub enum TriggerCondition {
    EntersBattlefield(FilterType),
    LeavesBattlefield(FilterType),
    CastSpell(FilterType),
    BeginningOfPhase(Phase),
    EndOfPhase(Phase),
    CreatureDies(FilterType),
    DamageDealt(DamageFilter),
    // Other trigger conditions...
}
}

Trigger Types

There are several types of triggers:

  • State triggers: "When/Whenever [state] is true..."
  • Event triggers: "When/Whenever [event] occurs..."
  • Phase/step triggers: "At the beginning of [phase/step]..."

Static Abilities

Static abilities apply continuously and don't use the stack. Examples include:

  • "Creatures you control get +1/+1."
  • "Opponent's spells cost {1} more to cast."
  • "You have hexproof."

Implementation

In Rummage, static abilities are implemented as follows:

#![allow(unused)]
fn main() {
pub struct StaticAbility {
    pub effect: StaticEffect,
    pub condition: Option<Condition>,
}

pub enum StaticEffect {
    ModifyPowerToughness(PTModification),
    ModifyCost(CostModification),
    GrantKeyword(KeywordGrant),
    PreventActions(ActionPrevention),
    ReplaceEffect(ReplacementEffect),
    // Other static effects...
}
}

Layering System

Static abilities that modify characteristics are applied in a specific order defined by the "layer system":

  1. Copy effects
  2. Control-changing effects
  3. Text-changing effects
  4. Type-changing effects
  5. Color-changing effects
  6. Ability-adding/removing effects
  7. Power/toughness changing effects

Keyword Abilities

Keyword abilities are shorthand for common abilities:

  • Flying: "This creature can only be blocked by creatures with flying or reach."
  • Haste: "This creature can attack and use {T} abilities as soon as it comes under your control."
  • Deathtouch: "Any amount of damage this deals to a creature is enough to destroy it."
  • Trample: "This creature can deal excess combat damage to the player or planeswalker it's attacking."

Implementation

In Rummage, keyword abilities are implemented as:

#![allow(unused)]
fn main() {
pub enum KeywordAbility {
    Flying,
    Haste,
    Vigilance,
    Trample,
    FirstStrike,
    DoubleStrike,
    Deathtouch,
    Lifelink,
    Reach,
    Hexproof,
    Indestructible,
    // Other keywords...
}
}

Loyalty Abilities

Planeswalkers have loyalty abilities, a special type of activated ability:

  • "+1: Draw a card."
  • "-2: Create a 3/3 Beast creature token."
  • "-8: You get an emblem with 'Creatures you control get +2/+2.'"

Implementation

Loyalty abilities are implemented as:

#![allow(unused)]
fn main() {
pub struct LoyaltyAbility {
    pub cost: i32, // Positive or negative loyalty change
    pub effect: AbilityEffect,
    pub target_requirement: Option<TargetRequirement>,
}
}

Implementation Status

The ability implementation status is:

  • ✅ Basic activated abilities
  • ✅ Mana abilities
  • ✅ Common keyword abilities
  • 🔄 Complex triggered abilities
  • 🔄 Replacement effects
  • 🔄 Layering system
  • ✅ Loyalty abilities

Casting Spells

This document outlines the rules for casting spells in Magic: The Gathering as implemented in Rummage.

Spell Casting Process

Casting a spell in Magic: The Gathering follows a specific sequence of steps:

  1. Announce the Spell: The player announces they are casting a spell and places it on the stack.
  2. Choose Modes: If the spell is modal, the player chooses which mode(s) to use.
  3. Choose Targets: If the spell requires targets, the player chooses legal targets.
  4. Choose Division of Effects: For spells that divide their effect, the player chooses how to divide it.
  5. Choose how to pay X: For spells with X in their cost, the player chooses the value of X.
  6. Determine Total Cost: Calculate the total cost, including additional or alternative costs.
  7. Activate Mana Abilities: The player activates mana abilities to generate mana.
  8. Pay Costs: The player pays all costs in any order.

After these steps, the spell is considered cast and is placed on the stack. It will resolve when all players pass priority in succession.

Implementation Details

In Rummage, spell casting is implemented using a state machine approach that guides the player through each step:

#![allow(unused)]
fn main() {
pub enum SpellCastingState {
    NotCasting,
    AnnouncingSpell,
    ChoosingModes,
    ChoosingTargets,
    DividingEffects,
    ChoosingX,
    CalculatingCost,
    GeneratingMana,
    PayingCosts,
    Completed,
}

pub struct SpellCasting {
    pub state: SpellCastingState,
    pub spell_entity: Option<Entity>,
    pub casting_player: Entity,
    pub modes_chosen: Vec<u32>,
    pub targets_chosen: Vec<TargetInfo>,
    pub divisions: Vec<u32>,
    pub x_value: u32,
    pub total_cost: ManaCost,
    pub paid_mana: Vec<ManaPayment>,
}
}

Types of Spells

Magic: The Gathering has several types of spells:

Permanent Spells

These become permanents on the battlefield when they resolve:

  • Creature spells
  • Artifact spells
  • Enchantment spells
  • Planeswalker spells
  • Land cards (technically not spells)

Non-Permanent Spells

These go to the graveyard when they resolve:

  • Instant spells
  • Sorcery spells

Timing Restrictions

Different spell types have different timing restrictions:

  • Instant: Can be cast at any time when a player has priority
  • Sorcery: Can only be cast during a player's main phase when the stack is empty and they have priority
  • Other spell types: Generally follow sorcery timing, with exceptions based on abilities

Special Cases

Split Cards

Split cards have two halves, and the player chooses which half to cast or may cast both halves with Fuse.

Modal spells allow the player to choose from multiple effects, with the number of choices often specified on the card.

Spells with X in the Cost

The player chooses a value for X, which affects both the spell's cost and its effect.

Alternative Costs

Some spells offer alternative ways to cast them, such as:

  • Flashback
  • Overload
  • Foretell
  • Adventure

UI Integration

The spell casting process integrates with the UI through:

  1. Card Selection: Players select a card to begin casting
  2. Target Selection: Visual interface for selecting targets
  3. Cost Payment: Interface for choosing which mana to pay
  4. Mode Selection: Interface for choosing modes for modal spells

For more on the UI implementation, see Card Selection.

Implementation Status

The spell casting implementation is:

  • ✅ Basic spell casting
  • ✅ Cost calculation
  • ✅ Target selection
  • ✅ Mana payment
  • 🔄 Alternative costs
  • 🔄 Complex modal spells
  • ✅ X spells

ECS Implementation of MTG Rules

This document explains how Magic: The Gathering rules are implemented using Bevy's Entity Component System (ECS) architecture in Rummage.

Table of Contents

  1. Introduction
  2. Entity Representations
  3. Component Design
  4. System Organization
  5. Event-Driven Mechanics
  6. Example Implementations

Introduction

Bevy's Entity Component System (ECS) architecture provides an ideal foundation for implementing Magic: The Gathering's complex rule system. This architecture offers several key advantages:

  • Data-Logic Separation: Game data (components) remains separate from game logic (systems)
  • Parallelism: Game mechanics can process in parallel where possible
  • Composition: Entities are composed of reusable components rather than inheritance hierarchies
  • Extensibility: New functionality can be added without modifying existing code

These benefits directly address the challenges of implementing MTG's intricate, interconnected rule system in a maintainable, testable, and performant way.

Entity Representations

In Rummage, we model the core MTG game elements as entities with specific components:

Cards

Cards are represented as entities with components describing their characteristics:

#![allow(unused)]
fn main() {
// Core card identity
#[derive(Component)]
struct Card {
    id: String,
    oracle_id: Uuid,
}

// Name component (separate for query efficiency)
#[derive(Component)]
struct CardName(String);

// Card type component
#[derive(Component)]
enum CardType {
    Creature,
    Instant,
    Sorcery,
    Artifact,
    Enchantment,
    Land,
    Planeswalker,
}

// Mana cost component
#[derive(Component)]
struct ManaCost {
    white: u8,
    blue: u8,
    black: u8,
    red: u8,
    green: u8,
    colorless: u8,
    generic: u8,
}

// Creature-specific components
#[derive(Component)]
struct Power(i32);

#[derive(Component)]
struct Toughness(i32);
}

Players

Players are entities with components tracking game state:

#![allow(unused)]
fn main() {
// Core player identity
#[derive(Component)]
struct Player {
    id: Uuid,
    name: String,
}

// Life total component
#[derive(Component)]
struct Life(i32);

// Mana pool component
#[derive(Component)]
struct ManaPool {
    white: u8,
    blue: u8,
    black: u8,
    red: u8,
    green: u8,
    colorless: u8,
}
}

Zones

Game zones are implemented as entities with specialized components:

#![allow(unused)]
fn main() {
// Zone type identifier
#[derive(Component)]
enum ZoneType {
    Battlefield,
    Hand,
    Library,
    Graveyard,
    Stack,
    Exile,
    Command,
}

// Container for entities in a zone
#[derive(Component)]
struct ZoneContents {
    entities: Vec<Entity>,
}

// Zone ownership
#[derive(Component)]
struct BelongsToPlayer(Entity);
}

Component Design

Our component design adheres to these core principles:

  1. Single Responsibility: Each component represents one specific aspect of a game entity
  2. Data-Oriented: Components store data only, not behavior
  3. Minimalist: Components include only necessary data to minimize memory usage
  4. Composable: Complex entities are built by combining simple components

This approach enables flexible entity composition. For example, a creature permanent on the battlefield might have:

#![allow(unused)]
fn main() {
// A creature permanent's components
Entity {
    Card { id: "c4a81753", oracle_id: "..." },
    CardName("Llanowar Elves"),
    CardType::Creature,
    Power(1),
    Toughness(1),
    InZone(ZoneType::Battlefield),
    UntappedState,
    TapForManaAbility { color: Green },
    CreatureType(vec!["Elf", "Druid"]),
    ControlledBy(player_entity),
    // Additional components for abilities, counters, etc.
}
}

System Organization

Systems implement game rules and mechanics, organized into logical categories:

Turn Structure Systems

Systems that handle the progression of game turns:

#![allow(unused)]
fn main() {
// Beginning of turn system
fn begin_turn_system(
    mut turn_state: ResMut<TurnState>,
    mut events: EventWriter<BeginTurnEvent>,
    query: Query<Entity, With<ActivePlayer>>,
) {
    if let Ok(active_player) = query.get_single() {
        events.send(BeginTurnEvent { player: active_player });
        turn_state.phase = Phase::Beginning;
        turn_state.step = Step::Untap;
    }
}

// Untap step system
fn untap_step_system(
    turn_state: Res<TurnState>,
    active_player: Query<Entity, With<ActivePlayer>>,
    mut permanents: Query<(Entity, &ControlledBy, &mut UntappedState)>,
) {
    // Skip if not in untap step
    if turn_state.phase != Phase::Beginning || turn_state.step != Step::Untap {
        return;
    }
    
    // Get active player
    let active_player = match active_player.get_single() {
        Ok(player) => player,
        Err(_) => return,
    };
    
    // Untap permanents controlled by active player
    for (_, controlled_by, mut untapped) in &mut permanents {
        if controlled_by.0 == active_player {
            *untapped = UntappedState::Untapped;
        }
    }
}
}

State-Based Actions

Systems that check and apply state-based effects:

#![allow(unused)]
fn main() {
// System for creatures with 0 or less toughness
fn check_creature_death(
    mut commands: Commands,
    creatures: Query<(Entity, &Toughness, &InZone), With<CardType::Creature>>,
    mut zone_events: EventWriter<ZoneChangeEvent>,
) {
    for (entity, toughness, zone) in &creatures {
        if toughness.0 <= 0 && zone.0 == ZoneType::Battlefield {
            // Move creature to graveyard
            zone_events.send(ZoneChangeEvent {
                entity,
                from: ZoneType::Battlefield,
                to: ZoneType::Graveyard,
                cause: ZoneChangeCause::StateBased,
            });
        }
    }
}

// System for players with 0 or less life
fn check_player_loss(
    players: Query<(Entity, &Life, &Player)>,
    mut game_events: EventWriter<GameEvent>,
) {
    for (entity, life, player) in &players {
        if life.0 <= 0 {
            game_events.send(GameEvent::PlayerLost {
                player: entity,
                reason: LossReason::ZeroLife,
            });
        }
    }
}
}

Spell Resolution

Systems for spell casting and resolution:

#![allow(unused)]
fn main() {
// Adding spells to the stack
fn cast_spell_system(
    mut commands: Commands,
    mut cast_events: EventReader<CastSpellEvent>,
    mut stack: ResMut<Stack>,
) {
    for event in cast_events.iter() {
        // Create stack object entity
        let stack_object = commands.spawn((
            StackObject,
            SourceCard(event.card),
            ControlledBy(event.controller),
            event.targets.clone(),
            // Additional stack object components
        )).id();
        
        // Add to stack
        stack.objects.push(stack_object);
    }
}

// Resolving the top object on the stack
fn resolve_top_of_stack(
    mut commands: Commands,
    mut stack: ResMut<Stack>,
    objects: Query<(Entity, &StackObject, &SourceCard, &ControlledBy)>,
    cards: Query<&CardType>,
    mut resolution_events: EventWriter<StackResolutionEvent>,
) {
    if let Some(top_object) = stack.objects.pop() {
        if let Ok((entity, _, source_card, controller)) = objects.get(top_object) {
            // Determine appropriate resolution based on card type
            if let Ok(card_type) = cards.get(source_card.0) {
                resolution_events.send(StackResolutionEvent {
                    stack_object: entity,
                    source: source_card.0,
                    controller: controller.0,
                    card_type: card_type.clone(),
                });
            }
        }
    }
}
}

Event-Driven Mechanics

MTG's reactive mechanics are implemented using Bevy's event system:

Game Events

#![allow(unused)]
fn main() {
// Card draw event
#[derive(Event)]
struct DrawCardEvent {
    player: Entity,
    amount: usize,
}

// Damage event
#[derive(Event)]
struct DamageEvent {
    source: Entity,
    target: Entity,
    amount: u32,
    is_combat_damage: bool,
}

// Zone change event
#[derive(Event)]
struct ZoneChangeEvent {
    entity: Entity,
    from: ZoneType,
    to: ZoneType,
    cause: ZoneChangeCause,
}
}

Event Handlers

Systems that respond to these events:

#![allow(unused)]
fn main() {
// Handle card drawing
fn handle_draw_event(
    mut events: EventReader<DrawCardEvent>,
    player_hands: Query<(Entity, &ZoneType, &BelongsToPlayer)>,
    player_libraries: Query<(Entity, &ZoneType, &BelongsToPlayer, &ZoneContents)>,
    mut commands: Commands,
    mut zone_events: EventWriter<ZoneChangeEvent>,
) {
    for event in events.iter() {
        // Find player's library and hand
        // Move top N cards from library to hand
        // ...
    }
}

// Handle damage
fn handle_damage_event(
    mut events: EventReader<DamageEvent>,
    mut players: Query<(Entity, &mut Life)>,
    mut creatures: Query<(Entity, &mut Toughness)>,
    mut damage_taken: Query<&mut DamageTaken>,
) {
    for event in events.iter() {
        // Apply damage based on target type
        // ...
    }
}
}

Example Implementations

This section provides complete examples of how complex MTG mechanics are implemented using the ECS architecture.

Creature Combat Example

Here's how the combat system is implemented:

#![allow(unused)]
fn main() {
// Declare attackers
fn declare_attackers_system(
    mut commands: Commands,
    turn_state: Res<TurnState>,
    mut attack_declarations: EventReader<DeclareAttackerEvent>,
    creatures: Query<(Entity, &ControlledBy, &UntappedState), With<CardType::Creature>>,
    active_player: Query<Entity, With<ActivePlayer>>,
    mut phase_events: EventWriter<PhaseChangeEvent>,
) {
    // Validate phase
    if turn_state.phase != Phase::Combat || turn_state.step != Step::DeclareAttackers {
        return;
    }
    
    // Process attack declarations
    for event in attack_declarations.iter() {
        if let Ok((entity, controller, untapped_state)) = creatures.get(event.creature) {
            // Check if creature is controlled by active player and untapped
            if let Ok(active) = active_player.get_single() {
                if controller.0 == active && matches!(untapped_state, UntappedState::Untapped) {
                    // Mark as attacking
                    commands.entity(entity).insert(Attacking { 
                        defending_player: event.target,
                        // Additional attack data
                    });
                    
                    // Tap the creature
                    commands.entity(entity).insert(UntappedState::Tapped);
                }
            }
        }
    }
    
    // When all declarations are done, advance to next step
    phase_events.send(PhaseChangeEvent {
        new_phase: Phase::Combat,
        new_step: Step::DeclareBlockers,
    });
}
}

Card Drawing and Libraries

Implementation of card drawing:

#![allow(unused)]
fn main() {
// Draw card system
fn draw_card_system(
    mut events: EventReader<DrawCardEvent>,
    players: Query<&Player>,
    libraries: Query<(Entity, &ZoneContents, &ZoneType, &BelongsToPlayer)>,
    hands: Query<(Entity, &ZoneType, &BelongsToPlayer)>,
    mut zone_events: EventWriter<ZoneChangeEvent>,
    mut game_events: EventWriter<GameEvent>,
) {
    for event in events.iter() {
        // Find player's library
        let player_library = libraries.iter()
            .find(|(_, _, zone_type, belongs_to)| {
                **zone_type == ZoneType::Library && belongs_to.0 == event.player
            });
        
        if let Some((library_entity, contents, _, _)) = player_library {
            // Check if player can draw enough cards
            if contents.entities.len() < event.amount {
                // Not enough cards - player loses
                game_events.send(GameEvent::PlayerLost {
                    player: event.player,
                    reason: LossReason::EmptyLibrary,
                });
                continue;
            }
            
            // Find player's hand
            let player_hand = hands.iter()
                .find(|(_, zone_type, belongs_to)| {
                    **zone_type == ZoneType::Hand && belongs_to.0 == event.player
                });
            
            if let Some((hand_entity, _, _)) = player_hand {
                // Move cards from library to hand
                for _ in 0..event.amount {
                    if let Some(&card) = contents.entities.last() {
                        zone_events.send(ZoneChangeEvent {
                            entity: card,
                            from: ZoneType::Library,
                            to: ZoneType::Hand,
                            cause: ZoneChangeCause::Draw,
                        });
                    }
                }
            }
        }
    }
}
}

Connecting ECS to MTG Rules

This ECS implementation maps directly to the MTG Comprehensive Rules:

  1. Entities: Correspond to objects in the MTG rules (cards, permanents, players)
  2. Components: Represent characteristics and states defined in the rules
  3. Systems: Implement rules procedures and state transitions
  4. Events: Model the discrete game events that trigger rule applications

This mapping ensures that the implementation accurately reflects the official rules while leveraging the performance and flexibility benefits of the ECS architecture.


For more specific implementations, see:

Commander Format

This section documents the implementation of the Commander (EDH) format in Rummage, a multiplayer format that emphasizes social gameplay, unique deck construction constraints, and strategic depth.

Note: For a high-level overview of the Commander format rules without implementation details, see the Commander-Specific Rules Reference.

Format Overview

Commander (formerly known as Elder Dragon Highlander or EDH) is a sanctioned Magic: The Gathering format with these defining characteristics:

FeatureDescriptionImplementation
Deck Construction100-card singleton decks (no duplicates except basic lands)Deck validation systems
CommanderA legendary creature that leads your deckCommand zone and casting mechanics
Color IdentityDeck colors must match commander's color identityDeck validation and color checking
Life Total40 starting life (vs. standard 20)Modified game initialization
Commander Damage21 combat damage from a single commander causes lossPer-commander damage tracking
Multiplayer FocusDesigned for 3-6 playersTurn ordering and multiplayer mechanics

Documentation Structure

The documentation is organized into the following sections:

  • Overview - High-level overview of the Commander format and implementation approach
  • Game Mechanics - Core game state and mechanics implementation
    • Game State Management
    • State-Based Actions
    • Random Mechanics (coin flips, dice rolls)
  • Player Mechanics - Player-specific rules and interactions
    • Life Total Management
    • Commander Tax
    • Color Identity
  • Game Zones - Implementation of game zones, especially the Command Zone
    • Command Zone
    • Zone Transfers
    • Zone-specific Rules
  • Turns and Phases - Turn structure and phase management
    • Turn Order
    • Phase Management
    • Multiplayer Considerations
  • Stack and Priority - Stack implementation and priority system
    • Priority Passing
    • Stack Resolution
    • Special Timing Rules
  • Combat - Combat mechanics including commander damage
    • Combat Phases
    • Commander Damage Tracking
    • Multiplayer Combat
  • Special Rules - Format-specific rules and unique mechanics
    • Partner Commanders
    • Commander Death Triggers
    • Commander-specific Abilities
  • Core Integration - How Commander extends MTG core rules

Key Mechanics Implementation

Command Zone

The Command Zone serves as the foundation of the format:

#![allow(unused)]
fn main() {
// Command Zone implementation
#[derive(Component)]
struct CommandZone {
    owner: Entity,
    contents: Vec<Entity>,
}

// Commander component
#[derive(Component)]
struct Commander {
    owner: Entity,
    cast_count: u32,
}
}

Key implementations:

  • Commanders start in the Command Zone
  • Zone transfer options when commanders change zones
  • Commander Tax calculation (2 additional mana per previous cast)

Commander Damage Tracking

#![allow(unused)]
fn main() {
// Tracking damage from each commander
#[derive(Component)]
struct CommanderDamageTracker {
    // Maps commander entities to damage received
    damage_taken: HashMap<Entity, u32>,
}

// System that checks for commander damage loss condition
fn check_commander_damage_loss(
    tracker: Query<(Entity, &CommanderDamageTracker, &Player)>,
    mut game_events: EventWriter<GameEvent>,
) {
    for (entity, tracker, player) in &tracker {
        for (_, damage) in tracker.damage_taken.iter() {
            if *damage >= 21 {
                game_events.send(GameEvent::PlayerLost {
                    player: entity,
                    reason: LossReason::CommanderDamage,
                });
                break;
            }
        }
    }
}
}

Key Commander Rules

The following key Commander rules are implemented in our engine:

RuleDescriptionImplementation Status
SingletonOnly one copy of each card allowed (except basic lands)
CommanderLegendary creature in command zone
Color IdentityCards must match commander's color identity
Command ZoneSpecial zone for commanders
Commander TaxAdditional {2} cost each time cast from command zone
Commander Damage21 combat damage from a single commander
Starting Life40 life points
Commander ReplacementOptional replacement to command zone
Partner CommandersSpecial commanders that can be paired🔄
Commander NinjutsuSpecial ability for certain commanders⚠️
Commander-specific CardsCards that reference the command zone or commanders🔄

Technical Implementation

The Commander format is implemented as a Bevy plugin that extends the core MTG rules:

#![allow(unused)]
fn main() {
pub struct CommanderPlugin;

impl Plugin for CommanderPlugin {
    fn build(&self, app: &mut App) {
        app
            // Commander components
            .register_type::<Commander>()
            .register_type::<CommanderDamage>()
            .register_type::<ColorIdentity>()
            
            // Commander resources
            .init_resource::<CommanderConfig>()
            
            // Commander systems
            .add_systems(Startup, commander_game_setup)
            .add_systems(
                PreUpdate,
                (check_commander_zone_transfers, validate_color_identity)
            )
            .add_systems(
                Update,
                (track_commander_damage, apply_commander_tax)
            );
    }
}
}

Testing Strategy

Commander testing focuses on these key areas:

  1. Rule Compliance: Verifying all Commander-specific rules
  2. Integration Testing: Testing interaction with core MTG systems
  3. Multiplayer Scenarios: Validating complex multiplayer situations
  4. Edge Cases: Partner commanders, commander ninjutsu, and other special mechanics

Each section includes detailed test cases to validate the correct implementation of Commander rules. Our testing approach ensures:

  1. Full coverage of Commander-specific rules
  2. Edge case handling for unique interactions
  3. Performance validation for multiplayer scenarios
  4. Verification of correct rule application in complex board states

For detailed testing approaches, see the Commander Testing Guide.

Implementation Status

FeatureStatusNotes
Command Zone✅ ImplementedComplete zone mechanics
Commander Casting✅ ImplementedWith tax calculation
Zone Transfers✅ ImplementedWith player choice
Commander Damage✅ ImplementedWith per-commander tracking
Color Identity✅ ImplementedDeck validation
Partner Commanders🔄 In ProgressBasic functionality working
Multiplayer Politics⚠️ PlannedDesign in progress

For more information on the official Commander rules, refer to the Commander Format Rules.

Commander Overview

This section provides a high-level overview of the Commander format implementation in our game engine.

Contents

Format Summary

The Commander format is a multiplayer variant of Magic: The Gathering with special rules including:

  • Each player begins the game with a designated legendary creature as their "commander"
  • Players start with 40 life
  • A player can cast their commander from the command zone for its mana cost, plus an additional 2 mana for each previous time it's been cast
  • If a commander would be put into a library, hand, graveyard or exile, its owner may choose to move it to the command zone
  • A player who has been dealt 21 or more combat damage by the same commander loses the game

Implementation Principles

Our implementation of the Commander format follows these key principles:

  1. Rule Accuracy - Faithful implementation of the official Commander rules
  2. Performance - Optimized for multiplayer games with complex board states
  3. Extensibility - Designed to easily incorporate new Commander-specific cards and mechanics
  4. Testability - Comprehensive test suite for validating format-specific rules

See the Implementation Approach document for more detailed information on how these principles are applied in our codebase.

  • Game Mechanics - For detailed mechanics implementation
  • Combat - For Commander damage tracking and combat interactions
  • Game Zones - For Command Zone implementation details

Commander Format History

Commander (formerly known as Elder Dragon Highlander or EDH) has an interesting history that evolved from a casual player-created format to one of Magic: The Gathering's most popular formats today.

Origins

The Commander format was created in the late 1990s by judges in Alaska who were looking for a new way to play between tournament rounds. The format was originally called "Elder Dragon Highlander" (EDH) because:

  • Players were required to use one of the five Elder Dragons from Legends as their general (later called commander)
  • The "Highlander" part referenced the 1986 film and its tagline "There can be only one," signifying the singleton nature of the format

Evolution of the Format

Time PeriodDevelopment
Late 1990sFormat created by Alaska judges
Early 2000sSpread through judge community and casual players
2005-2010Growing online presence and popularity
2011Wizards of the Coast officially recognized the format and released the first Commander preconstructed decks
2011+Annual commander products and growing mainstream popularity
PresentOne of Magic's most popular formats with dedicated products

Rules Committee

The Commander format is officially governed by the Commander Rules Committee (RC), an independent group that:

  • Maintains the format's rules and banned list
  • Makes decisions about format changes
  • Promotes the philosophy of Commander as a social, multiplayer format

The Rules Committee maintains that Commander should remain primarily a social format that emphasizes fun, memorable gameplay over competition.

Implementation in Rummage

In Rummage, we've faithfully implemented the Commander format according to the official rules while maintaining the flexibility to adapt to future changes. Our implementation:

  • Follows the current Commander rules
  • Includes historical context in design decisions
  • Allows for easy adaptation to rule changes

References

For more information on the official Commander rules and history:

Key Principles of Commander

The Commander format is built upon core design principles that guide both the rules and the social contract of the format. These principles inform our implementation in Rummage.

Social Focus

Commander was designed as a multiplayer, social format where:

  1. Fun and memorable gameplay takes precedence over competitive optimization
  2. Politics and table talk are encouraged
  3. The journey of the game is as important as the outcome
  4. Players are encouraged to build decks that express their identity and creativity

Format Pillars

Variance and Uniqueness

The singleton requirement (only one copy of each card except basic lands) ensures:

  • Every game plays differently
  • Decks feel unique and personalized
  • Players must be adaptable to changing game states
  • Card evaluation differs from other formats

Commander Identity

The commander serves as:

  • The centerpiece of the deck's strategy
  • A constant presence throughout the game
  • A representation of the player's identity
  • A build-around card that shapes deckbuilding decisions

Multiplayer Dynamics

Commander embraces multiplayer game design through:

  • Balanced starting life totals (40 life)
  • Political elements like deal-making and temporary alliances
  • Mechanics that encourage table-wide interactions
  • Rules that prevent early eliminations when possible

Design Philosophy

The Commander Rules Committee articulates the following design philosophy:

Commander is designed to promote social games of magic. It suits players who want to:

  • Cultivate a casual atmosphere where everyone can have fun, not just the winner
  • Build personalized decks that showcase interesting cards and strategies
  • Create game states full of twists and turns that lead to epic plays and memorable moments

Implementation in Rummage

Our implementation of Commander in Rummage emphasizes:

  1. Faithful Rules Implementation: Accurate implementation of the official rules
  2. Social Features: Support for politics, deals, and multiplayer interactions
  3. Player Expression: Flexible deck building and commander options
  4. Game Variance: Random elements and complexity that lead to unique game states
  5. Accessibility: Clear visualizations of complex board states and commander-specific rules

Rules vs. Social Contract

We recognize that Commander has both written rules and an unwritten social contract. While we can fully implement the rules, the social contract is up to players. We provide:

  • Tools for communication between players
  • Flexibility in game settings
  • Support for house rules when possible
  • Documentation that explains both rules and social expectations

Commander Format Rules

Overview

Commander (formerly known as Elder Dragon Highlander or EDH) is a multiplayer variant of Magic: The Gathering with special rules defined in section 903 of the Magic: The Gathering Comprehensive Rules.

Core Rules

  1. Deck Construction (rule 903.5)

    • 100-card singleton format (only one copy of each card except for basic lands)
    • Deck can only include cards that match the color identity of the commander
    • Color identity includes all colored mana symbols in the card's cost, rules text, and color indicator
  2. Commander Card (rule 903.3)

    • Each player designates a legendary creature as their "commander"
    • The commander is placed in the command zone at the start of the game
    • Partner commanders and Background mechanics allow for special exceptions
  3. Life Total (rule 903.7)

    • Players start with 40 life (instead of the standard 20)
  4. Command Zone Mechanics (rules 903.8, 903.9)

    • Commanders start in the command zone
    • Commanders can be cast from the command zone for their mana cost, plus an additional {2} for each previous time they've been cast from the command zone ("commander tax")
    • If a commander would be put into a library, hand, graveyard, or exile, its owner may choose to move it to the command zone instead
  5. Commander Damage (rule 903.10)

    • A player who has been dealt 21 or more combat damage by the same commander loses the game
  6. Banned Cards (rule 903.11)

    • The Commander format has its own banned list maintained by the Commander Rules Committee

Additional Rules

  1. Multiplayer Rules

    • Standard multiplayer rules apply (first player doesn't draw, etc.)
    • Free-for-all, attack-anyone format unless playing with teams
  2. Color Identity

    • A card's color identity includes all mana symbols in its cost and rules text
    • Cards in a player's deck must only contain mana symbols that appear in their commander's color identity
    • Basic land types implicitly have the corresponding mana symbol in their identity
  3. Replacement Commander

    • In casual play, if a player's commander would be put into a library, hand, graveyard or exile from anywhere, that player may put it into the command zone instead

These rules create a unique gameplay experience that emphasizes multiplayer interaction, powerful cards, and distinctive deck construction constraints.

Implementation Approach

Core Principles

Our implementation of the Commander format follows these key principles:

  1. Rules Accuracy - Faithfully implement the official Magic: The Gathering Comprehensive Rules for Commander (section 903)
  2. Modularity - Clear separation of concerns between different aspects of the game engine
  3. Testability - Comprehensive testing for complex rule interactions
  4. Performance - Optimized for multiplayer games with up to 13 players
  5. Extensibility - Easily accommodate new Commander variants and house rules

Technology Stack

The implementation uses the following technologies:

  1. Bevy ECS - For game state management and systems organization
  2. Rust - For type safety and performance
  3. Event-driven architecture - For game actions and triggers
  4. Automated testing - For rules validation and edge cases

Key Implementation Techniques

  1. Component-Based Design

    #![allow(unused)]
    fn main() {
    // Components for different aspects of game entities
    #[derive(Component)]
    pub struct Commander {
        pub cast_count: u32,
    }
    
    #[derive(Component)]
    pub struct CommanderDamage {
        // Maps commander entity ID to damage received
        pub damage_received: HashMap<Entity, u32>,
    }
    }
  2. Resource-Based Game State

    #![allow(unused)]
    fn main() {
    #[derive(Resource)]
    pub struct CommanderGameState {
        pub active_player_index: usize,
        pub player_order: Vec<Entity>,
        pub current_phase: Phase,
        // More fields
    }
    }
  3. Event-Driven Actions

    #![allow(unused)]
    fn main() {
    #[derive(Event)]
    pub struct CommanderCastEvent {
        pub commander: Entity,
        pub player: Entity,
        pub from_zone: Zone,
    }
    }
  4. Systems for Game Logic

    #![allow(unused)]
    fn main() {
    fn commander_damage_system(
        mut commands: Commands,
        game_state: Res<CommanderGameState>,
        mut damage_events: EventReader<CommanderDamageEvent>,
        mut players: Query<(Entity, &mut CommanderDamage)>,
    ) {
        // Logic implementation
    }
    }

Development Approach

  1. Incremental Implementation

    • Start with core game state management
    • Layer in Commander-specific rules
    • Add multiplayer functionality
    • Implement special cases and edge conditions
  2. Testing Strategy

    • Unit tests for individual components
    • Integration tests for system interactions
    • Scenario-based tests for complex rule interactions
    • Performance tests for multiplayer scenarios
  3. Documentation

    • Comprehensive documentation of implementation details
    • Cross-referencing with official rules
    • Examples of complex interactions

This structured approach ensures a robust implementation of the Commander format that accurately reflects the official rules while maintaining performance and extensibility.

Architecture Overview

System Architecture

The Commander rules engine is organized into several interconnected modules that work together to provide a complete implementation of the Commander format:

  1. Game State Management

    • Core state tracking and game flow coordination
    • Central resource for game metadata and state
    • Integration point for all other systems
  2. Player Management

    • Player data structures and tracking
    • Life total management (starting at 40)
    • Commander damage tracking
  3. Command Zone Management

    • Special zone for Commander cards
    • Commander casting and tax implementation
    • Zone transition rules
  4. Turn Structure & Phases

    • Complete turn sequence implementation
    • Priority passing in multiplayer context
    • Phase-based effects and triggers
  5. Combat System

    • Multiplayer combat implementation
    • Commander damage tracking
    • Attack declaration and blocking in multiplayer
  6. Priority & Stack

    • Priority passing algorithms for multiplayer
    • Stack implementation for spells and abilities
    • Resolution mechanics in complex scenarios
  7. State-Based Actions

    • Game state checks including commander damage threshold (21)
    • Format-specific state checks
    • Automatic game actions
  8. Special Commander Rules

    • Color identity validation
    • Commander zone movement replacement effects
    • Partner and Background mechanics
  9. Multiplayer Politics

    • Voting mechanics
    • Deal-making systems
    • Multiplayer-specific card effects

Integration with Bevy ECS

The Commander implementation leverages Bevy ECS (Entity Component System) for game state management:

#![allow(unused)]
fn main() {
// Game state as a Bevy resource
#[derive(Resource)]
pub struct CommanderGameState {
    // Game state fields
}

// Player as an entity with components
#[derive(Component)]
pub struct CommanderPlayer {
    // Player data
}

// Systems for game logic
fn process_commander_damage(
    mut game_state: ResMut<CommanderGameState>,
    query: Query<(&CommanderPlayer, &CommanderDamage)>,
) {
    // Implementation
}
}

The architecture allows for clean separation of concerns while maintaining the complex relationships between different aspects of the Commander format.

Game Mechanics

This section covers the core game mechanics specific to the Commander format implementation.

Contents

The game mechanics section defines how the Commander format's unique rules are implemented and enforced within our game engine, including:

  • Commander-specific state tracking
  • Unique state-based actions like commander damage checks
  • Special triggered abilities for command zone interactions
  • Format-specific mechanics like Lieutenant and Partner triggers
  • Handling multiple player elimination and game completion
  • Randomized mechanics like coin flips and dice rolls

These mechanics build on the foundational Magic: The Gathering rules while incorporating the unique aspects of the Commander format. The implementation is designed to be both accurate to the official rules and performant for multiplayer gameplay.

Game Engine Testing Guide

Overview

This guide outlines the comprehensive testing approach for the Rummage Magic: The Gathering Commander game engine. Testing a complex game engine requires multiple layers of validation to ensure rules are correctly implemented and interactions work as expected.

Testing Philosophies

The Rummage testing strategy follows these core philosophies:

  1. Rules-First Testing: Test cases are derived directly from the MTG Comprehensive Rules
  2. Isolation and Integration: Test components in isolation before testing their interactions
  3. Edge Case Coverage: Explicitly test corner cases and unusual card interactions
  4. Performance Validation: Ensure the engine performs well under various game conditions
  5. Reproducibility: All tests should be deterministic and repeatable
  6. Visual Consistency: Ensure consistent rendering across platforms and game states

Testing Layers

The game engine testing is structured in layers:

1. Unit Tests

Unit tests validate individual components in isolation. Each module should have comprehensive unit tests covering:

  • Basic functionality
  • Edge cases
  • Error handling
  • Public interface contracts

Example for the stack system:

#![allow(unused)]
fn main() {
#[test]
fn test_stack_push_and_resolve() {
    let mut app = App::new();
    // Setup minimal test environment
    app.add_plugins(MinimalPlugins)
       .add_plugin(StackPlugin);
    
    // Add a spell to the stack
    let spell_entity = app.world.spawn_empty().id();
    app.world.send_event(StackPushEvent {
        entity: spell_entity,
        source: None,
    });
    
    // Run systems
    app.update();
    
    // Verify stack state
    let stack = app.world.resource::<Stack>();
    assert_eq!(stack.items.len(), 1);
    
    // Resolve the top item
    app.world.send_event(StackResolveTopEvent);
    app.update();
    
    // Verify stack is empty
    let stack = app.world.resource::<Stack>();
    assert_eq!(stack.items.len(), 0);
}
}

2. Integration Tests

Integration tests verify the interaction between multiple components. Key integration scenarios include:

  • Turn structure and phase progression
  • Combat resolution
  • Stack interaction with permanents
  • Zone transitions

Example integration test:

#![allow(unused)]
fn main() {
#[test]
fn test_spell_cast_and_resolve_integration() {
    let mut app = App::new();
    // Setup more complete environment
    app.add_plugins(GameEngineTestPlugins)
       .add_systems(Startup, setup_test_game);
    
    // Create test player and card
    let (player, card) = setup_test_player_with_card(&mut app);
    
    // Cast the spell
    app.world.send_event(CastSpellEvent {
        player,
        card,
        targets: Vec::new(),
        mode: CastMode::Normal,
    });
    
    // Run systems to process the cast
    app.update();
    
    // Verify card moved to stack
    let stack = app.world.resource::<Stack>();
    assert!(stack.contains(card));
    
    // Resolve the stack
    resolve_stack_completely(&mut app);
    
    // Verify expected outcome based on card type
    let card_type = app.world.get::<CardType>(card).unwrap();
    match *card_type {
        CardType::Creature => {
            // Verify creature entered battlefield
            let battlefield = app.world.resource::<Battlefield>();
            assert!(battlefield.contains(card));
        },
        CardType::Sorcery => {
            // Verify sorcery went to graveyard
            let graveyard = get_player_graveyard(&app, player);
            assert!(graveyard.contains(card));
        },
        // Handle other card types
        _ => {}
    }
}
}

3. End-to-End Tests

End-to-end tests simulate complete game scenarios to validate the engine as a whole:

#![allow(unused)]
fn main() {
#[test]
fn test_complete_game_scenario() {
    let mut app = App::new();
    // Setup full game environment
    app.add_plugins(FullGameTestPlugins)
       .add_systems(Startup, setup_full_test_game);
    
    // Load predefined scenario
    let scenario = TestScenario::load("scenarios/two_player_creature_combat.json");
    scenario.apply_to_app(&mut app);
    
    // Run a fixed number of turns
    run_turns(&mut app, 3);
    
    // Verify expected game state
    let game_state = app.world.resource::<GameState>();
    assert_eq!(game_state.active_player_index, 1);
    
    // Verify player life totals
    let players = app.world.query::<&Player>().iter(&app.world).collect::<Vec<_>>();
    assert_eq!(players[0].life_total, 35);
    assert_eq!(players[1].life_total, 38);
    
    // Verify battlefield state
    let battlefield = app.world.resource::<Battlefield>();
    assert_eq!(battlefield.creatures_for_player(players[0].entity).count(), 2);
    assert_eq!(battlefield.creatures_for_player(players[1].entity).count(), 1);
}
}

4. Visual Differential Testing

Visual differential testing ensures consistent rendering across platforms and updates:

#![allow(unused)]
fn main() {
#[test]
fn test_card_rendering_consistency() {
    let mut app = App::new();
    app.add_plugins(MinimalPlugins)
       .add_plugin(VisualTestingPlugin)
       .add_systems(Startup, setup_card_rendering_test);
    
    // Configure test environment
    let card_states = [
        "in_hand", 
        "on_battlefield", 
        "tapped", 
        "with_counters"
    ];
    
    for state in &card_states {
        // Configure card state
        setup_card_state(&mut app, state);
        app.update();
        
        // Capture rendering
        if let Some(screenshot) = take_screenshot(&app) {
            // Compare with reference
            match load_reference_image(&format!("card_{}.png", state)) {
                Ok(reference) => {
                    let result = compare_images(&screenshot, &reference);
                    assert!(
                        result.similarity_score > 0.99, 
                        "Card rendering for state '{}' differs from reference", 
                        state
                    );
                },
                Err(_) => {
                    // Generate reference if not exists
                    let _ = save_reference_image(screenshot, &format!("card_{}.png", state));
                }
            }
        }
    }
}
}

Test Data Management

Card Test Database

A specialized test card database simplifies testing of specific interactions:

#![allow(unused)]
fn main() {
// Access test cards by specific properties
let board_wipe = test_cards::get_card("board_wipe");
let counter_spell = test_cards::get_card("counter_spell");
let indestructible_creature = test_cards::get_card("indestructible_creature");

// Test interaction
test_interaction(board_wipe, indestructible_creature);
}

Scenario Files

Predefined test scenarios enable reproducible complex game states:

{
  "players": [
    {
      "name": "Player 1",
      "life": 40,
      "battlefield": ["test_cards/serra_angel", "test_cards/sol_ring"],
      "hand": ["test_cards/counterspell", "test_cards/lightning_bolt"],
      "graveyard": ["test_cards/llanowar_elves"]
    },
    {
      "name": "Player 2",
      "life": 36,
      "battlefield": ["test_cards/goblin_guide", "test_cards/birds_of_paradise"],
      "hand": ["test_cards/wrath_of_god"],
      "graveyard": []
    }
  ],
  "turn": {
    "active_player": 0,
    "phase": "main1",
    "priority_player": 0
  }
}

Testing Particular Systems

Mana System Testing

The mana system requires specific testing for:

  1. Mana Production: Test ability to produce mana from various sources
  2. Mana Payment: Test payment for spells and abilities
  3. Mana Restrictions: Test "spend only on X" restrictions
  4. Color Identity: Test commander color identity rules
#![allow(unused)]
fn main() {
#[test]
fn test_mana_restrictions() {
    // Test setup
    let mut app = App::new();
    app.add_plugins(MinimalPlugins)
       .add_plugin(ManaPlugin);
    
    // Create a player with a mana pool
    let player = setup_test_player(&mut app);
    
    // Add mana with "spend this mana only on creature spells" restriction
    add_restricted_mana(&mut app, player, ManaColor::Green, 3, ManaRestriction::CreatureSpells);
    
    // Test allowed payment
    let creature_card = spawn_test_card(&mut app, "Grizzly Bears");
    let result = try_pay_mana_cost(&mut app, player, creature_card);
    assert!(result.is_success);
    
    // Test restricted payment
    let noncreature_card = spawn_test_card(&mut app, "Giant Growth");
    let result = try_pay_mana_cost(&mut app, player, noncreature_card);
    assert!(result.is_failure);
    assert_eq!(result.failure_reason, ManaPaymentFailure::RestrictionViolation);
}
}

Combat System Testing

Combat testing should validate:

  1. Attack Declaration: Rules about who can attack
  2. Blocker Declaration: Valid blocking assignments
  3. Combat Damage: Correct damage assignment and processing
  4. Combat Effects: Triggers that happen during combat
#![allow(unused)]
fn main() {
#[test]
fn test_combat_damage_assignment() {
    let mut app = App::new();
    app.add_plugins(GameEngineTestPlugins);
    
    // Setup attacker with 4/4 stats
    let attacker = spawn_test_creature(&mut app, 4, 4);
    
    // Setup two blockers: 2/2 and 1/1
    let blocker1 = spawn_test_creature(&mut app, 2, 2);
    let blocker2 = spawn_test_creature(&mut app, 1, 1);
    
    // Declare attack
    declare_attacker(&mut app, attacker);
    
    // Declare blockers
    declare_blockers(&mut app, vec![blocker1, blocker2], attacker);
    
    // Assign damage: 2 to first blocker, 2 to second blocker
    assign_combat_damage(&mut app, attacker, vec![(blocker1, 2), (blocker2, 2)]);
    
    // Process damage
    process_combat_damage(&mut app);
    
    // Verify results
    assert!(is_creature_dead(&app, blocker1));
    assert!(is_creature_dead(&app, blocker2));
    assert!(!is_creature_dead(&app, attacker));
}
}

Stack and Priority Testing

Testing stack interactions requires:

  1. Proper Sequencing: Items resolve in LIFO order
  2. Priority Passing: Correct priority assignment during resolution
  3. Interruption: Ability to respond to items on the stack
  4. Special Actions: Actions that don't use the stack
#![allow(unused)]
fn main() {
#[test]
fn test_stack_priority_and_responses() {
    let mut app = App::new();
    app.add_plugins(GameEngineTestPlugins);
    
    // Setup players
    let player1 = setup_test_player(&mut app);
    let player2 = setup_test_player(&mut app);
    
    // Setup cards
    let lightning_bolt = spawn_test_card(&mut app, "Lightning Bolt");
    let counterspell = spawn_test_card(&mut app, "Counterspell");
    
    // Give cards to players
    give_card_to_player(&mut app, lightning_bolt, player1);
    give_card_to_player(&mut app, counterspell, player2);
    
    // Player 1 casts Lightning Bolt
    cast_spell(&mut app, player1, lightning_bolt, Some(player2));
    
    // Verify Lightning Bolt is on the stack
    let stack = app.world.resource::<Stack>();
    assert_eq!(stack.items.len(), 1);
    
    // Player 2 responds with Counterspell
    cast_spell(&mut app, player2, counterspell, Some(lightning_bolt));
    
    // Verify both spells are on the stack
    let stack = app.world.resource::<Stack>();
    assert_eq!(stack.items.len(), 2);
    assert_eq!(stack.items[0].card, counterspell);
    assert_eq!(stack.items[1].card, lightning_bolt);
    
    // Resolve stack
    resolve_stack_completely(&mut app);
    
    // Verify both cards went to graveyard and Lightning Bolt didn't deal damage
    assert!(is_card_in_graveyard(&app, counterspell, player2));
    assert!(is_card_in_graveyard(&app, lightning_bolt, player1));
    
    let player2_resource = app.world.get::<Player>(player2).unwrap();
    assert_eq!(player2_resource.life_total, 40); // Unchanged
}
}

Testing Best Practices

Arrange-Act-Assert Pattern

Follow the Arrange-Act-Assert pattern in test implementation:

#![allow(unused)]
fn main() {
#[test]
fn test_some_functionality() {
    // ARRANGE: Set up the test environment
    let mut app = App::new();
    app.add_plugins(MinimalPlugins)
       .add_plugin(SystemUnderTest);
    
    let test_entity = setup_test_entity(&mut app);
    
    // ACT: Perform the action being tested
    perform_action(&mut app, test_entity);
    app.update(); // Run systems
    
    // ASSERT: Verify expected outcomes
    let result = get_result(&app, test_entity);
    assert_eq!(result, expected_value);
}
}

Use Test Fixtures

Create reusable test fixtures to simplify test implementation:

#![allow(unused)]
fn main() {
// Fixture for tests involving combat
fn setup_combat_fixture(app: &mut App) -> CombatFixture {
    app.add_plugins(MinimalPlugins)
       .add_plugin(CombatPlugin);
       
    let player1 = app.world.spawn((Player { life_total: 40, ..Default::default() })).id();
    let player2 = app.world.spawn((Player { life_total: 40, ..Default::default() })).id();
    
    let attacker = app.world.spawn((
        Creature { power: 3, toughness: 3, ..Default::default() },
        Permanent { controller: player1, ..Default::default() },
    )).id();
    
    let blocker = app.world.spawn((
        Creature { power: 2, toughness: 2, ..Default::default() },
        Permanent { controller: player2, ..Default::default() },
    )).id();
    
    CombatFixture {
        player1,
        player2,
        attacker,
        blocker,
    }
}
}

Focused Test Cases

Keep test cases focused on a single behavior or requirement:

#![allow(unused)]
fn main() {
// GOOD: Focused test
#[test]
fn creatures_with_deathtouch_destroy_blockers() {
    // Test setup
    let creature_with_deathtouch = setup_deathtouch_creature();
    let normal_creature = setup_normal_creature();
    
    // Combat interaction
    simulate_combat(creature_with_deathtouch, normal_creature);
    
    // Verification
    assert!(normal_creature.is_destroyed());
}

// BAD: Unfocused test
#[test]
fn test_deathtouch_and_trample_and_first_strike() {
    // Too many interactions being tested at once
    // Makes it hard to understand test failures
}
}

Property-Based Testing

Use property-based testing for rules that should hold across many inputs:

#![allow(unused)]
fn main() {
#[test]
fn test_mana_payment_properties() {
    proptest!(|(cost: ManaCost, mana_pool: ManaPool)| {
        // Property: If payment succeeds, the mana pool should decrease by exactly the cost
        let initial_total = mana_pool.total_mana();
        let result = pay_mana_cost(cost, &mut mana_pool.clone());
        
        if result.is_success {
            let new_total = mana_pool.total_mana();
            let used_mana = initial_total - new_total;
            
            // The mana used should equal the cost
            prop_assert_eq!(used_mana, cost.total_mana());
        }
    });
}
}

Test Debugging Tools

Logging in Tests

Use descriptive logging to help debug test failures:

#![allow(unused)]
fn main() {
#[test]
fn test_with_detailed_logging() {
    // Set up the test
    info!("Setting up test with player 1 having 3 creatures and player 2 having 2 enchantments");
    
    // Configure logging level for test
    let mut app = App::new();
    app.add_plugins(MinimalPlugins)
       .add_plugin(LogPlugin {
            level: Level::DEBUG,
            filter: "test=debug,game_engine=debug".to_string(),
       });
    
    // Detailed action description
    debug!("Player 1 casting board wipe spell");
    
    // Log outcome for debugging
    info!("Expected: All creatures destroyed, enchantments remain. Got: {} creatures, {} enchantments",
          remaining_creatures, remaining_enchantments);
}
}

State Snapshots

Create snapshots of game state for easier debugging:

#![allow(unused)]
fn main() {
#[test]
fn test_complex_interaction() {
    let mut app = App::new();
    // Setup test
    
    // Take snapshot before action
    let before_snapshot = take_game_state_snapshot(&app);
    save_snapshot("before_action.json", &before_snapshot);
    
    // Perform action
    perform_complex_action(&mut app);
    
    // Take snapshot after action
    let after_snapshot = take_game_state_snapshot(&app);
    save_snapshot("after_action.json", &after_snapshot);
    
    // Verify expected changes
    verify_state_changes(&before_snapshot, &after_snapshot);
}
}

Performance Testing

Include performance testing as part of your test suite:

#![allow(unused)]
fn main() {
#[test]
fn benchmark_large_board_state() {
    let mut app = App::new();
    app.add_plugins(GameEngineTestPlugins);
    
    // Create a large board state
    setup_large_board_state(&mut app, 100); // 100 permanents per player
    
    // Measure time for operations
    let start = std::time::Instant::now();
    process_turn_cycle(&mut app);
    let duration = start.elapsed();
    
    // Log performance results
    info!("Processing turn with large board took: {:?}", duration);
    
    // Assert performance requirements
    assert!(duration < std::time::Duration::from_millis(100),
            "Turn processing too slow: {:?}", duration);
}
}

Continuous Integration

Ensure your tests run in CI:

# .github/workflows/tests.yml
name: Game Engine Tests

on:
  push:
    branches: [ main ]
  pull_request:
    branches: [ main ]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - name: Install Rust
        uses: actions-rs/toolchain@v1
        with:
          profile: minimal
          toolchain: stable
      
      - name: Run unit tests
        run: cargo test --lib
        
      - name: Run integration tests
        run: cargo test --test integration
        
      - name: Run visual tests
        run: cargo test --test visual
        
      - name: Run performance tests
        run: cargo test --test performance

Common Testing Patterns

Singleton Resource Validation

#![allow(unused)]
fn main() {
#[test]
fn test_resource_update() {
    let mut app = App::new();
    app.add_plugins(MinimalPlugins);
    
    // Add initial resource
    app.insert_resource(GameState {
        turn_count: 0,
        active_player: 0,
    });
    
    // Add system that increments turn count
    app.add_systems(Update, increment_turn_system);
    
    // Run system
    app.update();
    
    // Verify resource was updated
    let game_state = app.world.resource::<GameState>();
    assert_eq!(game_state.turn_count, 1);
}
}

Event Testing

#![allow(unused)]
fn main() {
#[test]
fn test_event_handling() {
    let mut app = App::new();
    app.add_plugins(MinimalPlugins);
    
    // Add event and system
    app.add_event::<CardDrawEvent>();
    app.add_systems(Update, handle_card_draw);
    
    // Send test event
    app.world.resource_mut::<Events<CardDrawEvent>>().send(CardDrawEvent {
        player: Entity::from_raw(1),
        count: 3,
    });
    
    // Run system
    app.update();
    
    // Verify event was handled (check side effects)
    // ...
}
}

Component Addition/Removal

#![allow(unused)]
fn main() {
#[test]
fn test_component_addition() {
    let mut app = App::new();
    app.add_plugins(MinimalPlugins);
    
    // System that adds components
    app.add_systems(Update, add_damage_component_system);
    
    // Create test entity
    let entity = app.world.spawn(Creature {
        power: 2,
        toughness: 2,
    }).id();
    
    // Trigger damage
    app.world.resource_mut::<Events<DamageEvent>>().send(DamageEvent {
        target: entity,
        amount: 1,
    });
    
    // Run system
    app.update();
    
    // Verify component was added
    assert!(app.world.get::<Damaged>(entity).is_some());
    assert_eq!(app.world.get::<Damaged>(entity).unwrap().amount, 1);
}
}

Conclusion

A comprehensive testing strategy is essential for the Rummage MTG Commander game engine. By combining unit tests, integration tests, end-to-end tests, and visual tests, we can ensure that the engine correctly implements the MTG rules and provides a consistent player experience.

Remember, an untested game engine is a source of bugs and inconsistencies. Invest time in creating a robust test suite, and it will pay dividends in reduced debugging time and improved game quality.

State-Based Actions

Overview

State-Based Actions (SBAs) are checks that the game automatically performs regularly, ensuring game rules are enforced without requiring player actions. These checks occur whenever a player would receive priority and before any player actually receives priority.

Core Functionality

In Commander, state-based actions handle crucial game state validations:

  • Player loss conditions (life total at or below 0, drawing from an empty library, poison counters)
  • Commander damage tracking (21+ damage from a single commander)
  • Creature destruction (zero or negative toughness, lethal damage)
  • Planeswalker loyalty management
  • Aura and Equipment attachment rules
  • Legendary permanents uniqueness rule
  • Token existence rules
  • Counters on permanents (especially +1/+1 and -1/-1 counter interaction)

Implementation

The state-based actions system is implemented as a collection of systems that run between steps and phases:

#![allow(unused)]
fn main() {
#[derive(Resource)]
pub struct StateBasedActionSystem {
    // Configuration for SBA processing
    pub enabled: bool,
    pub last_check_time: Duration,
    pub check_frequency: Duration,
    
    // Tracking for specific SBAs
    pub pending_legendary_checks: Vec<Entity>,
    pub pending_destruction: Vec<Entity>,
    pub pending_life_checks: Vec<Entity>,
}

// Systems that implement specific checks
pub fn check_player_loss_conditions(/* ... */);
pub fn check_creature_destruction(/* ... */);
pub fn check_attachment_validity(/* ... */);
pub fn check_legendary_rule(/* ... */);
pub fn check_token_existence(/* ... */);
pub fn check_counter_interactions(/* ... */);
}

Special Commander Rules

Commander format introduces specific state-based actions:

  • Commander Damage: When a player has been dealt 21 or more combat damage by the same commander, they lose the game
  • Commander Zone Transitions: Commanders can be moved to the command zone instead of other zones
  • Color Identity: Cards that would be added to a library, hand, battlefield or graveyard are checked against the commander's color identity

Multiplayer Considerations

In multiplayer Commander:

  • State-based actions are checked simultaneously for all players and permanents
  • Player elimination is handled through SBAs, with appropriate triggers firing
  • When a player leaves the game, all cards they own leave the game (with special rules for controlled permanents)

Optimization

The SBA system is optimized to:

  • Only check relevant game objects (using spatial partitioning where appropriate)
  • Use efficient data structures to track pending actions
  • Batch similar checks where possible
  • Cache results when appropriate

Commander-Specific Triggered Abilities

This document details the implementation of triggered abilities that are specific to or modified by the Commander format.

Overview

Triggered abilities in Commander generally follow the same rules as in standard Magic: The Gathering. However, there are several Commander-specific triggers and interactions that require special implementation:

  1. Triggers that reference the command zone
  2. Commander-specific card triggers
  3. Multiplayer-specific triggers
  4. Commander death and zone change triggers

Command Zone Triggers

Triggered abilities that interact with the command zone require special handling:

#![allow(unused)]
fn main() {
/// Component for abilities that trigger when a commander enters or leaves the command zone
#[derive(Component)]
pub struct CommandZoneTrigger {
    /// The event that triggers this ability
    pub trigger_event: CommandZoneEvent,
    /// The effect to apply
    pub effect: Box<dyn CommandZoneEffect>,
}

/// Events related to the command zone
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CommandZoneEvent {
    /// Triggers when a commander enters the command zone
    OnEnter,
    /// Triggers when a commander leaves the command zone
    OnLeave,
    /// Triggers when a commander is cast from the command zone
    OnCast,
}

/// System that handles command zone triggers
pub fn handle_command_zone_triggers(
    mut commands: Commands,
    mut zone_transition_events: EventReader<ZoneTransitionEvent>,
    mut cast_events: EventReader<CastFromCommandZoneEvent>,
    command_zone_triggers: Query<(Entity, &CommandZoneTrigger, &Controller)>,
    commanders: Query<(Entity, &Commander, &Owner)>,
) {
    // Handle zone transitions
    for event in zone_transition_events.read() {
        // Check if this is a commander
        if let Ok((commander_entity, _, owner)) = commanders.get(event.entity) {
            // Handle entering command zone
            if event.to == Zone::Command {
                trigger_command_zone_abilities(
                    CommandZoneEvent::OnEnter,
                    commander_entity,
                    owner.0,
                    &command_zone_triggers,
                    &mut commands,
                );
            }
            
            // Handle leaving command zone
            if event.from == Zone::Command {
                trigger_command_zone_abilities(
                    CommandZoneEvent::OnLeave,
                    commander_entity,
                    owner.0,
                    &command_zone_triggers,
                    &mut commands,
                );
            }
        }
    }
    
    // Handle cast events
    for event in cast_events.read() {
        trigger_command_zone_abilities(
            CommandZoneEvent::OnCast,
            event.commander,
            event.controller,
            &command_zone_triggers,
            &mut commands,
        );
    }
}

fn trigger_command_zone_abilities(
    event: CommandZoneEvent,
    commander: Entity,
    controller: Entity,
    triggers: &Query<(Entity, &CommandZoneTrigger, &Controller)>,
    commands: &mut Commands,
) {
    // Find all triggers that match this event
    for (trigger_entity, trigger, trigger_controller) in triggers.iter() {
        // Only trigger if the ability belongs to the controller of the commander
        if trigger_controller.0 == controller && trigger.trigger_event == event {
            // Create and resolve the triggered ability
            let ability_id = commands.spawn_empty().id();
            
            // Set up ability context
            let mut ctx = AbilityContext {
                ability: ability_id,
                source: trigger_entity,
                controller,
                targets: vec![commander],
                additional_data: HashMap::new(),
            };
            
            // Apply the effect
            trigger.effect.resolve(commands.world_mut(), &mut ctx);
        }
    }
}
}

Commander Death Triggers

The Commander format has specific rules about commanders changing zones and death triggers:

#![allow(unused)]
fn main() {
/// System that handles commander death triggers
pub fn handle_commander_death_triggers(
    mut death_events: EventReader<DeathEvent>,
    mut commander_death_events: EventWriter<CommanderDeathEvent>,
    commanders: Query<&Commander>,
) {
    for event in death_events.read() {
        if commanders.contains(event.entity) {
            // Create a commander-specific death event
            commander_death_events.send(CommanderDeathEvent {
                commander: event.entity,
                controller: event.controller,
                cause: event.cause.clone(),
            });
        }
    }
}

/// Event for when a commander dies
#[derive(Event)]
pub struct CommanderDeathEvent {
    /// The commander that died
    pub commander: Entity,
    /// The controller of the commander
    pub controller: Entity,
    /// What caused the death
    pub cause: DeathCause,
}

/// System that handles the option to put commander in command zone instead of graveyard
pub fn handle_commander_zone_choice(
    mut commands: Commands,
    mut death_events: EventReader<DeathEvent>,
    mut zone_choice_events: EventWriter<CommanderZoneChoiceEvent>,
    commanders: Query<&Commander>,
    owners: Query<&Owner>,
) {
    for event in death_events.read() {
        if commanders.contains(event.entity) {
            if let Ok(owner) = owners.get(event.entity) {
                // Create a zone choice event for the commander's owner
                zone_choice_events.send(CommanderZoneChoiceEvent {
                    commander: event.entity,
                    owner: owner.0,
                    source_zone: Zone::Battlefield,
                    destination_options: vec![Zone::Graveyard, Zone::Command],
                });
            }
        }
    }
}
}

Lieutenant Triggers

"Lieutenant" is a Commander-specific mechanic that gives bonuses when you control your commander:

#![allow(unused)]
fn main() {
/// Component for Lieutenant abilities
#[derive(Component)]
pub struct Lieutenant {
    /// The effect that happens when you control your commander
    pub effect: Box<dyn LieutenantEffect>,
}

/// System that checks for and applies Lieutenant effects
pub fn check_lieutenant_condition(
    mut commands: Commands,
    lieutenant_cards: Query<(Entity, &Lieutenant, &Controller)>,
    battlefield: Query<(Entity, &Controller), With<OnBattlefield>>,
    commanders: Query<(Entity, &Commander)>,
) {
    // For each card with a Lieutenant ability
    for (lieutenant_entity, lieutenant, controller) in lieutenant_cards.iter() {
        // Check if controller controls their commander on the battlefield
        let controls_commander = battlefield
            .iter()
            .filter(|(entity, card_controller)| {
                // Must be controlled by same player as Lieutenant card
                card_controller.0 == controller.0 &&
                // Must be a commander
                commanders.contains(*entity)
            })
            .count() > 0;
        
        // Apply or remove the Lieutenant effect based on condition
        if controls_commander {
            // Apply the effect if not already applied
            if !commands.entity(lieutenant_entity).contains::<ActiveLieutenantEffect>() {
                commands.entity(lieutenant_entity).insert(ActiveLieutenantEffect);
                lieutenant.effect.apply(commands.world_mut(), lieutenant_entity);
            }
        } else {
            // Remove the effect if it was applied
            if commands.entity(lieutenant_entity).contains::<ActiveLieutenantEffect>() {
                commands.entity(lieutenant_entity).remove::<ActiveLieutenantEffect>();
                lieutenant.effect.remove(commands.world_mut(), lieutenant_entity);
            }
        }
    }
}

/// Marker component for entities with active Lieutenant effects
#[derive(Component)]
pub struct ActiveLieutenantEffect;
}

Partner Triggers

Partner commanders have unique triggered abilities:

#![allow(unused)]
fn main() {
/// Component for "Partner with" tutor ability
#[derive(Component)]
pub struct PartnerWithTutorAbility {
    /// The name of the partner card to search for
    pub partner_name: String,
}

/// System that handles the "Partner with" tutor trigger
pub fn handle_partner_tutor_trigger(
    mut commands: Commands,
    mut entered_battlefield_events: EventReader<EnteredBattlefieldEvent>,
    partner_tutors: Query<(Entity, &PartnerWithTutorAbility, &Controller)>,
    mut tutor_events: EventWriter<TutorEvent>,
) {
    for event in entered_battlefield_events.read() {
        if let Ok((entity, tutor, controller)) = partner_tutors.get(event.entity) {
            // Create a tutor event to find the partner
            tutor_events.send(TutorEvent {
                player: controller.0,
                card_name: tutor.partner_name.clone(),
                destination_zone: Zone::Hand,
                source: entity,
                optional: true,
            });
        }
    }
}
}

Commander Damage Triggers

Commander damage has a specific trigger at 21 damage:

#![allow(unused)]
fn main() {
/// System that checks for lethal commander damage
pub fn check_commander_damage(
    mut commands: Commands,
    mut damage_events: EventReader<CommanderDamageEvent>,
    mut defeat_events: EventWriter<PlayerDefeatEvent>,
    commander_damage: Query<&CommanderDamageTracker>,
) {
    for event in damage_events.read() {
        // Get damage tracker for the damaged player
        if let Ok(damage_tracker) = commander_damage.get(event.damaged_player) {
            // Check if any commander has dealt 21+ damage
            for (commander, damage) in damage_tracker.damage.iter() {
                if *damage >= 21 {
                    // Player is defeated by commander damage
                    defeat_events.send(PlayerDefeatEvent {
                        player: event.damaged_player,
                        defeat_reason: DefeatReason::CommanderDamage {
                            commander: *commander,
                        },
                    });
                    
                    break;
                }
            }
        }
    }
}
}

Multiplayer-Specific Triggers

Commander's multiplayer nature leads to unique triggered abilities:

#![allow(unused)]
fn main() {
/// Component for "attack trigger" abilities that scale with number of opponents attacked
#[derive(Component)]
pub struct AttackTriggeredAbility {
    /// The effect to apply
    pub effect: Box<dyn AttackEffect>,
    /// Multiplier for multi-opponent attacks
    pub scales_with_opponents: bool,
}

/// System that handles attack triggers in multiplayer
pub fn handle_attack_triggers(
    mut commands: Commands,
    mut attack_events: EventReader<AttackEvent>,
    attack_abilities: Query<(Entity, &AttackTriggeredAbility, &Controller)>,
) {
    // Group attacks by controller to count unique opponents
    let mut controller_attacks: HashMap<Entity, HashSet<Entity>> = HashMap::new();
    
    for event in attack_events.read() {
        if let Ok(attacker_controller) = controllers.get(event.attacker) {
            controller_attacks
                .entry(attacker_controller.0)
                .or_default()
                .insert(event.defender);
        }
    }
    
    // Trigger abilities based on attacks
    for (ability_entity, ability, controller) in attack_abilities.iter() {
        if let Some(attacked_opponents) = controller_attacks.get(&controller.0) {
            if !attacked_opponents.is_empty() {
                if ability.scales_with_opponents {
                    // Apply effect with scaling factor
                    let scale_factor = attacked_opponents.len() as i32;
                    ability.effect.apply_scaled(
                        commands.world_mut(), 
                        ability_entity, 
                        scale_factor
                    );
                } else {
                    // Apply effect normally
                    ability.effect.apply(commands.world_mut(), ability_entity);
                }
            }
        }
    }
}
}

Testing Commander Triggers

Testing Commander-specific triggers requires special test fixtures:

#![allow(unused)]
fn main() {
#[test]
fn test_lieutenant_trigger() {
    let mut app = App::new();
    setup_test_game(&mut app);
    
    // Create a player
    let player = app.world.spawn(Player).id();
    
    // Create a commander in the command zone
    let commander = app.world.spawn((
        CardName("Test Commander".to_string()),
        Commander,
        Controller(player),
    )).id();
    
    // Create a card with Lieutenant ability
    let lieutenant = app.world.spawn((
        CardName("Test Lieutenant".to_string()),
        OnBattlefield,
        Controller(player),
        Lieutenant {
            effect: Box::new(TestLieutenantEffect),
        },
    )).id();
    
    // Run the lieutenant check system
    app.update();
    
    // Initially, commander not on battlefield, so no effect
    assert!(!app.world.entity(lieutenant).contains::<ActiveLieutenantEffect>());
    
    // Move commander to battlefield
    app.world.entity_mut(commander).insert(OnBattlefield);
    
    // Run the lieutenant check system again
    app.update();
    
    // Now the effect should be active
    assert!(app.world.entity(lieutenant).contains::<ActiveLieutenantEffect>());
    
    // Remove commander from battlefield
    app.world.entity_mut(commander).remove::<OnBattlefield>();
    
    // Run system again
    app.update();
    
    // Effect should be removed
    assert!(!app.world.entity(lieutenant).contains::<ActiveLieutenantEffect>());
}
}

Integration with Core Triggered Abilities

Commander-specific triggered abilities integrate with the core triggered ability system:

#![allow(unused)]
fn main() {
pub fn register_commander_triggered_abilities(app: &mut App) {
    app
        .add_event::<CommanderDeathEvent>()
        .add_event::<CommanderZoneChoiceEvent>()
        .add_systems(Update, (
            handle_command_zone_triggers,
            handle_commander_death_triggers,
            handle_commander_zone_choice,
            check_lieutenant_condition,
            handle_partner_tutor_trigger,
            check_commander_damage,
            handle_attack_triggers,
        ).after(CoreTriggerSystems));
}
}

Random Elements in Commander

This document outlines how random elements in Magic: The Gathering are implemented in the Commander format within Rummage.

Types of Random Elements

Magic: The Gathering contains several types of random elements that require implementation in a digital engine:

ElementDescriptionExamples
Coin FlipsBinary random outcomeKrark's Thumb, Mana Crypt
Die RollsRandom number generationDelina, Wild Mage; Chaos effects
Random Card SelectionSelecting cards at randomGoblin Lore, Chaos Warp
Random Target SelectionChoosing targets randomlyPossibility Storm, Knowledge Pool
Random Card GenerationCreating cards not in the original deckBooster Tutor, Garth One-Eye

Implementation Approach

In Rummage, we implement random elements using a deterministic RNG system that:

  1. Maintains synchronization across networked games
  2. Provides verifiable randomness that can be audited
  3. Seeds random generators consistently across client instances
  4. Allows for replaying game states with identical outcomes

Code Example: Deterministic RNG

#![allow(unused)]
fn main() {
use bevy::prelude::*;
use rand::{Rng, SeedableRng};
use rand_chacha::ChaCha8Rng;

#[derive(Resource)]
pub struct GameRng(ChaCha8Rng);

impl GameRng {
    pub fn new_seeded(seed: u64) -> Self {
        Self(ChaCha8Rng::seed_from_u64(seed))
    }
    
    pub fn flip_coin(&mut self) -> bool {
        self.0.gen_bool(0.5)
    }
    
    pub fn roll_die(&mut self, sides: u32) -> u32 {
        self.0.gen_range(1..=sides)
    }
    
    pub fn select_random_index(&mut self, max: usize) -> usize {
        if max == 0 {
            return 0;
        }
        self.0.gen_range(0..max)
    }
}

// System that handles coin flips
fn handle_coin_flip(
    mut commands: Commands,
    mut rng: ResMut<GameRng>,
    mut coin_flip_events: EventReader<CoinFlipEvent>,
) {
    for event in coin_flip_events.read() {
        let result = rng.flip_coin();
        commands.spawn(CoinFlipResult {
            source: event.source,
            result,
            affected_entities: event.affected_entities.clone(),
        });
    }
}
}

Synchronization with Multiplayer

In multiplayer Commander games, random outcomes must be identical for all players. To achieve this:

  1. The host acts as the source of truth for RNG seed
  2. RNG state is included in the synchronized game state
  3. Random events are processed deterministically
  4. Network desync detection checks RNG state

Testing Random Elements

Random elements require special testing approaches:

  1. Seeded Tests: Using known seeds to produce predictable outcomes
  2. Distribution Tests: Verifying statistical properties over many trials
  3. Edge Case Tests: Testing boundary conditions and extreme outcomes
  4. Determinism Tests: Ensuring identical outcomes given the same seed

Example Test

#![allow(unused)]
fn main() {
#[test]
fn test_coin_flip_determinism() {
    // Create two RNGs with the same seed
    let mut rng1 = GameRng::new_seeded(12345);
    let mut rng2 = GameRng::new_seeded(12345);
    
    // They should produce identical sequences
    for _ in 0..100 {
        assert_eq!(rng1.flip_coin(), rng2.flip_coin());
    }
}
}

Commander-Specific Concerns

In Commander, random elements interact with multiplayer politics in unique ways:

  1. Perception of fairness in random outcomes
  2. Impact of random effects on multiple players simultaneously
  3. Using random outcomes as leverage in political negotiations
  4. Varying player attitudes toward randomness and luck

Our implementation supports these dynamics by:

  • Providing clear visualization of random processes
  • Implementing rules for modifying random outcomes (like Krark's Thumb)
  • Supporting multiplayer-specific random effects
  • Allowing customization of random elements in house rules

Summary

Random elements in Commander are implemented using deterministic, network-synchronized systems that maintain game integrity while supporting the unique social and political dynamics of the format.

Game State Management

Overview

The Game State Management module is responsible for tracking and maintaining the complete state of a Commander game. It serves as the central source of truth for all game data and coordinates the interactions between other modules, ensuring rules compliance according to the Magic: The Gathering Comprehensive Rules section 903.

Core Components

GameState Resource

#![allow(unused)]
fn main() {
#[derive(Resource)]
pub struct CommanderGameState {
    // Game metadata
    pub game_id: uuid::Uuid,
    pub turn_number: u32,
    pub start_time: std::time::Instant,
    
    // Active player and turn state
    pub active_player_index: usize,
    pub player_order: Vec<Entity>,
    pub current_phase: Phase,
    pub priority_holder: Entity,
    
    // Game parameters
    pub starting_life: u32,  // 40 for Commander (rule 903.7)
    pub max_players: u8,     // Up to 13 supported
    pub commander_damage_threshold: u32,  // 21 (rule 903.10a)
    
    // Game state flags
    pub is_game_over: bool,
    pub winner: Option<Entity>,
    
    // Special Commander format states
    pub commander_cast_count: HashMap<Entity, u32>,  // For commander tax
    pub eliminated_players: Vec<Entity>,
}
}

State Tracking Components

#![allow(unused)]
fn main() {
#[derive(Component)]
pub struct CommanderGameParticipant {
    pub player_index: usize,
    pub is_active: bool,
}

#[derive(Component)]
pub struct TurnOrderPosition {
    pub position: usize,
    pub is_skipping_turn: bool,
}

#[derive(Component, Default)]
pub struct GameStateFlags {
    pub has_played_land: bool,
    pub commanders_in_play: HashSet<Entity>,
    pub has_attacked_this_turn: bool,
}
}

Key Systems

Game State Initialization

#![allow(unused)]
fn main() {
pub fn initialize_commander_game(
    mut commands: Commands,
    mut game_state: ResMut<CommanderGameState>,
    players: Query<Entity, With<Player>>,
) {
    // Set up initial game state
    game_state.game_id = uuid::Uuid::new_v4();
    game_state.turn_number = 1;
    game_state.start_time = std::time::Instant::now();
    
    // Set up player order (randomized)
    let mut player_entities = players.iter().collect::<Vec<_>>();
    player_entities.shuffle(&mut rand::thread_rng());
    game_state.player_order = player_entities;
    
    // Set initial active player
    game_state.active_player_index = 0;
    game_state.priority_holder = player_entities[0];
    
    // Set starting phase
    game_state.current_phase = Phase::Beginning(BeginningPhaseStep::Untap);
    
    // Initialize Commander-specific parameters
    game_state.starting_life = 40;  // Commander rule 903.7
    game_state.commander_damage_threshold = 21;  // Commander rule 903.10a
    
    // Tag entities with player index
    for (idx, entity) in player_entities.iter().enumerate() {
        commands.entity(*entity).insert(CommanderGameParticipant {
            player_index: idx,
            is_active: idx == 0,
        });
        
        commands.entity(*entity).insert(TurnOrderPosition {
            position: idx,
            is_skipping_turn: false,
        });
        
        commands.entity(*entity).insert(GameStateFlags::default());
    }
}
}

Game State Update System

#![allow(unused)]
fn main() {
pub fn update_game_state(
    mut game_state: ResMut<CommanderGameState>,
    mut players: Query<(Entity, &mut CommanderGameParticipant, &PlayerState)>,
    time: Res<Time>,
) {
    // Check for eliminated players
    for (entity, participant, state) in players.iter() {
        if state.life_total <= 0 && !game_state.eliminated_players.contains(&entity) {
            game_state.eliminated_players.push(entity);
        }
    }
    
    // Check for game over condition
    let remaining_players = players
        .iter()
        .filter(|(entity, _, _)| !game_state.eliminated_players.contains(&entity))
        .count();
    
    if remaining_players <= 1 {
        game_state.is_game_over = true;
        if remaining_players == 1 {
            let winner = players
                .iter()
                .find(|(entity, _, _)| !game_state.eliminated_players.contains(&entity))
                .map(|(entity, _, _)| entity);
            game_state.winner = winner;
        }
    }
}
}

State Validation

The game state management module includes validation functions to ensure game state consistency:

#![allow(unused)]
fn main() {
pub fn validate_game_state(game_state: &CommanderGameState) -> Result<(), String> {
    // Validate player count
    if game_state.player_order.is_empty() {
        return Err("Game must have at least one player".to_string());
    }
    
    if game_state.player_order.len() > game_state.max_players as usize {
        return Err(format!("Game cannot have more than {} players", game_state.max_players));
    }
    
    // Validate active player
    if game_state.active_player_index >= game_state.player_order.len() {
        return Err("Active player index out of bounds".to_string());
    }
    
    // More validations...
    
    Ok(())
}
}

Integration Points

The game state module integrates with other systems through:

  1. Resource access - Other systems can read the game state
  2. Events - Game state changes trigger events for other systems
  3. Commands - Game state can be updated through commands

This module forms the foundation of the Commander game engine, providing the central state management needed to coordinate all other game systems.

Random Mechanics Test Cases

Overview

This document outlines test cases for Magic: The Gathering cards and mechanics involving randomness, including coin flips, dice rolls, and random selections. When implementing these mechanics, it's crucial to ensure deterministic testing while maintaining fair randomness in actual gameplay.

Randomness in Commander Format

The Commander format includes many cards with random elements that require special handling:

  1. Commander-Specific Random Cards: Many popular Commander cards use coin flips, dice rolls, or random selection as a core mechanic (e.g., Okaun, Eye of Chaos, Zndrsplt, Eye of Wisdom, Yusri, Fortune's Flame)

  2. Multiplayer Considerations: Random effects in multiplayer games need to ensure all players can verify the randomness

  3. Deterministic Testing: While gameplay should be truly random, our testing requires deterministic outcomes

  4. Network Implementation: In networked games, random results must be synchronized across all clients

This document provides test cases to ensure these mechanics work properly within the Commander format implementation.

Test Principles

  1. Deterministic Testing: All random operations must be mockable for testing
  2. Seed Control: Tests should use fixed seeds for reproducibility
  3. Distribution Validation: Test that random operations have the expected distribution over many trials
  4. User Interface: Test that random events are properly communicated to players

Coin Flip Test Cases

Basic Coin Flip Mechanics

#![allow(unused)]
fn main() {
#[test]
fn test_basic_coin_flip() {
    // Create a test game with mocked RNG using a predetermined seed
    let mut game = TestGame::new_with_seed(12345);
    
    // Add Krark's Thumb to player 1's battlefield
    // "If you would flip a coin, instead flip two coins and ignore one of them."
    game.add_card_to_battlefield(1, "Krark's Thumb");
    
    // Add Chance Encounter to player 1's battlefield
    // "Whenever you win a coin flip, put a luck counter on Chance Encounter.
    // At the beginning of your upkeep, if Chance Encounter has ten or more luck counters on it, you win the game."
    game.add_card_to_battlefield(1, "Chance Encounter");
    
    // Cast Stitch in Time
    // "Flip a coin. If you win the flip, take an extra turn after this one."
    game.cast_spell(1, "Stitch in Time");
    
    // Verify the coin flip sequence was triggered
    assert!(game.event_occurred(GameEvent::CoinFlipStarted));
    
    // With our test seed, we know the first flip should be a win
    // and the second flip would be a loss, but Krark's Thumb lets us ignore one
    assert_eq!(game.get_extra_turns_count(1), 1);
    
    // A luck counter should be added to Chance Encounter
    assert_eq!(game.get_counters(Card::by_name("Chance Encounter"), CounterType::Luck), 1);
}
}

Commander with Coin Flip Abilities

#![allow(unused)]
fn main() {
#[test]
fn test_okaun_zndrsplt_interaction() {
    // Create a test game with mocked RNG
    let mut game = TestGame::new_with_seed(42);
    
    // Add Okaun, Eye of Chaos and Zndrsplt, Eye of Wisdom as commanders for player 1
    // Okaun: "Whenever you win a coin flip, double Okaun's power and toughness until end of turn."
    // Zndrsplt: "Whenever you win a coin flip, draw a card."
    game.add_commander(1, "Okaun, Eye of Chaos");
    game.add_commander(1, "Zndrsplt, Eye of Wisdom");
    
    // Cast Frenetic Efreet
    // "{0}: Flip a coin. If you win the flip, Frenetic Efreet phases out.
    // If you lose the flip, sacrifice Frenetic Efreet."
    game.cast_spell(1, "Frenetic Efreet");
    
    // Activate ability three times, with predetermined results: win, lose, win
    game.set_next_coin_flips([true, false, true]);
    
    for _ in 0..3 {
        game.activate_ability(Card::by_name("Frenetic Efreet"), 0);
        game.resolve_stack();
    }
    
    // Verify Okaun's power/toughness was doubled twice (for two wins)
    // Base P/T is 3/3, doubled twice = 12/12
    let okaun = game.get_battlefield_card(1, "Okaun, Eye of Chaos");
    assert_eq!(game.get_power(okaun), 12);
    assert_eq!(game.get_toughness(okaun), 12);
    
    // Verify player drew 2 cards from Zndrsplt's ability
    assert_eq!(game.get_hand_size(1), 2);
    
    // Verify Frenetic Efreet was sacrificed on the loss
    assert!(game.in_graveyard(Card::by_name("Frenetic Efreet")));
}
}

Edge Cases

#![allow(unused)]
fn main() {
#[test]
fn test_coin_flip_replacement_effects() {
    let mut game = TestGame::new_with_seed(7777);
    
    // Add Krark's Thumb to player 1's battlefield
    game.add_card_to_battlefield(1, "Krark's Thumb");
    
    // Add Goblin Archaeologist to battlefield
    // "{T}: Flip a coin. If you win the flip, destroy target artifact.
    // If you lose the flip, sacrifice Goblin Archaeologist."
    game.add_card_to_battlefield(1, "Goblin Archaeologist");
    
    // Add a target artifact
    game.add_card_to_battlefield(2, "Sol Ring");
    
    // Add Chance Encounter
    game.add_card_to_battlefield(1, "Chance Encounter");
    
    // Add Tavern Scoundrel
    // "Whenever you win one or more coin flips, create a Treasure token."
    game.add_card_to_battlefield(1, "Tavern Scoundrel");
    
    // Setup test for replacement effect - ensure both flips are a "win"
    game.set_next_coin_flips([true, true]);
    
    // Activate Goblin Archaeologist targeting Sol Ring
    game.activate_ability_with_target(
        Card::by_name("Goblin Archaeologist"), 
        0, 
        Card::by_name("Sol Ring")
    );
    game.resolve_stack();
    
    // Verify Sol Ring was destroyed
    assert!(game.in_graveyard(Card::by_name("Sol Ring")));
    
    // Verify Goblin Archaeologist is still on the battlefield
    assert!(game.on_battlefield(Card::by_name("Goblin Archaeologist")));
    
    // Verify we got only ONE Treasure token despite flipping two coins
    // (Tavern Scoundrel triggers on "one or more" wins, not per win)
    assert_eq!(game.count_permanents_by_type(1, "Treasure"), 1);
    
    // Verify Chance Encounter got a luck counter
    assert_eq!(game.get_counters(Card::by_name("Chance Encounter"), CounterType::Luck), 1);
}
}

Dice Rolling Test Cases

Basic Dice Rolling

#![allow(unused)]
fn main() {
#[test]
fn test_basic_dice_rolling() {
    let mut game = TestGame::new_with_seed(42);
    
    // Add Delina, Wild Mage to battlefield
    // "Whenever Delina, Wild Mage attacks, roll a d20. If you roll a 15 or higher, 
    // create a token that's a copy of target creature you control..."
    game.add_card_to_battlefield(1, "Delina, Wild Mage");
    
    // Add a target creature
    game.add_card_to_battlefield(1, "Llanowar Elves");
    
    // Set up attack
    game.begin_combat();
    game.declare_attacker(Card::by_name("Delina, Wild Mage"), 2);
    
    // Mock the d20 roll to be 17
    game.set_next_dice_roll(20, 17);
    
    // Resolve Delina's triggered ability, targeting Llanowar Elves
    game.choose_target_for_ability(
        Card::by_name("Delina, Wild Mage"), 
        Card::by_name("Llanowar Elves")
    );
    game.resolve_stack();
    
    // Verify a token copy was created
    assert_eq!(game.count_permanents_by_name(1, "Llanowar Elves"), 2);
    
    // Verify the token has haste (part of Delina's ability)
    let token = game.get_tokens_by_name(1, "Llanowar Elves")[0];
    assert!(game.has_ability(token, Ability::Haste));
}
}

Commander With Dice Rolling Abilities

#![allow(unused)]
fn main() {
#[test]
fn test_farideh_commander() {
    let mut game = TestGame::new_with_seed(42);
    
    // Add Farideh, Devil's Chosen as commander for player 1
    // "Whenever you roll one or more dice, Farideh, Devil's Chosen gains flying and menace until end of turn.
    // Whenever you roll a natural 20 on a d20, put a +1/+1 counter on Farideh."
    game.add_commander(1, "Farideh, Devil's Chosen");
    
    // Cast Pixie Guide
    // "If you would roll a d20, instead roll two d20s and use the higher roll."
    game.cast_spell(1, "Pixie Guide");
    
    // Cast Light of Hope
    // "Roll a d20. If you roll a 1-9, create a 3/3 Angel creature token with flying.
    // If you roll a 10-19, you gain 3 life for each creature you control.
    // If you roll a 20, until end of turn, creatures you control gain +2/+2 and acquire flying."
    game.cast_spell(1, "Light of Hope");
    
    // Set dice rolls to 8 and 20 (Pixie Guide will use the 20)
    game.set_next_dice_rolls(20, [8, 20]);
    game.resolve_stack();
    
    // Verify the abilities triggered by the natural 20
    // 1. Creatures get +2/+2 and flying from Light of Hope
    // 2. Farideh gets a +1/+1 counter from her ability
    // 3. Farideh gets flying and menace from her first ability
    
    // Check Farideh's power/toughness (base 3/3 + 1 counter + 2 from Light of Hope = 6/6)
    let farideh = game.get_battlefield_card(1, "Farideh, Devil's Chosen");
    assert_eq!(game.get_power(farideh), 6);
    assert_eq!(game.get_toughness(farideh), 6);
    assert_eq!(game.get_counters(farideh, CounterType::PlusOnePlusOne), 1);
    
    // Check abilities
    assert!(game.has_ability(farideh, Ability::Flying));
    assert!(game.has_ability(farideh, Ability::Menace));
    
    // Pixie Guide should also have +2/+2 and flying from Light of Hope
    let pixie = game.get_battlefield_card(1, "Pixie Guide");
    assert_eq!(game.get_power(pixie), game.get_base_power(pixie) + 2);
    assert!(game.has_ability(pixie, Ability::Flying));
}
}

Random Selection Test Cases

Random Discard

#![allow(unused)]
fn main() {
#[test]
fn test_random_discard() {
    let mut game = TestGame::new_with_seed(42);
    
    // Set up player 2's hand with known cards
    let cards = ["Island", "Lightning Bolt", "Dark Ritual", "Divination", "Doom Blade"];
    for card in cards {
        game.add_card_to_hand(2, card);
    }
    
    // Cast Hypnotic Specter with player 1
    game.add_card_to_battlefield(1, "Hypnotic Specter");
    
    // Deal damage to trigger discard
    game.deal_damage(Card::by_name("Hypnotic Specter"), Player(2), 1);
    
    // Set up the random discard to choose Lightning Bolt
    game.set_next_random_card_index(1); // 0-indexed, so this is the second card
    game.resolve_stack();
    
    // Verify Lightning Bolt was discarded
    assert!(game.in_graveyard(Card::by_name("Lightning Bolt")));
    assert_eq!(game.get_hand_size(2), 4);
}
}

Random Target Selection

#![allow(unused)]
fn main() {
#[test]
fn test_random_target_selection() {
    let mut game = TestGame::new_with_seed(42);
    
    // Setup battlefield with multiple creatures
    game.add_card_to_battlefield(2, "Grizzly Bears");
    game.add_card_to_battlefield(2, "Hill Giant");
    game.add_card_to_battlefield(2, "Shivan Dragon");
    
    // Cast Confusion in the Ranks with player 1
    // "Whenever a permanent enters the battlefield, its controller chooses target permanent 
    // another player controls that shares a type with it. Exchange control of those permanents."
    game.add_card_to_battlefield(1, "Confusion in the Ranks");
    
    // Cast Mogg Fanatic (creature)
    game.cast_spell(1, "Mogg Fanatic");
    
    // Mogg Fanatic enters, triggering Confusion in the Ranks
    // Set the random target to be Hill Giant (index 1 of the 3 valid targets)
    game.set_next_random_target_index(1);
    game.resolve_stack();
    
    // Verify Hill Giant is now controlled by player 1
    assert_eq!(game.get_controller(Card::by_name("Hill Giant")), Player(1));
    
    // Verify Mogg Fanatic is now controlled by player 2
    assert_eq!(game.get_controller(Card::by_name("Mogg Fanatic")), Player(2));
}
}

Integration with Commander Rules

Commander Damage with Chaotic Strike

#![allow(unused)]
fn main() {
#[test]
fn test_commander_damage_with_random_double_strike() {
    let mut game = TestGame::new_with_seed(42);
    
    // Setup Player 1's commander
    game.add_commander(1, "Gisela, Blade of Goldnight");
    
    // Cast Chaos Warp targeting an unimportant permanent
    // "The owner of target permanent shuffles it into their library, 
    // then reveals the top card of their library. If it's a permanent card, 
    // they put it onto the battlefield."
    game.add_card_to_hand(1, "Chaos Warp");
    game.cast_spell_with_target(1, "Chaos Warp", Card::by_name("Forest"));
    
    // Set up the random reveal to be Berserker's Onslaught
    // "Attacking creatures you control have double strike."
    game.set_next_reveal_card("Berserker's Onslaught");
    game.resolve_stack();
    
    // Move to combat
    game.begin_combat();
    game.declare_attacker(Card::by_name("Gisela, Blade of Goldnight"), 2);
    
    // Resolve combat damage
    game.resolve_combat_damage();
    
    // Verify commander damage - should be doubled from Berserker's Onslaught
    // Gisela is a 5/5 flying first strike, so should deal 10 commander damage with double strike
    assert_eq!(game.get_commander_damage(2, Card::by_name("Gisela, Blade of Goldnight")), 10);
}
}

Mock Framework for Testing Random Events

The test cases above reference various functions for mocking random events. Here's an example implementation for the test framework:

#![allow(unused)]
fn main() {
impl TestGame {
    /// Create a new test game with a fixed seed for reproducible randomness
    pub fn new_with_seed(seed: u64) -> Self {
        let mut game = Self::new();
        game.set_rng_seed(seed);
        game
    }
    
    /// Set the results of the next coin flips
    pub fn set_next_coin_flips(&mut self, results: impl Into<Vec<bool>>) {
        self.coin_flip_results = results.into();
    }
    
    /// Set the result of a single upcoming coin flip
    pub fn set_next_coin_flip(&mut self, result: bool) {
        self.coin_flip_results = vec![result];
    }
    
    /// Set the results of the next dice rolls of a specific size (e.g., d20)
    pub fn set_next_dice_rolls(&mut self, sides: u32, results: impl Into<Vec<u32>>) {
        self.dice_roll_results.insert(sides, results.into());
    }
    
    /// Set the result of a single upcoming dice roll
    pub fn set_next_dice_roll(&mut self, sides: u32, result: u32) {
        self.dice_roll_results.insert(sides, vec![result]);
    }
    
    /// Set which card will be chosen in a random selection (by index)
    pub fn set_next_random_card_index(&mut self, index: usize) {
        self.random_card_indices = vec![index];
    }
    
    /// Set which target will be chosen in a random selection (by index)
    pub fn set_next_random_target_index(&mut self, index: usize) {
        self.random_target_indices = vec![index];
    }
    
    /// Set the next card to be revealed from a library
    pub fn set_next_reveal_card(&mut self, card_name: &str) {
        self.next_reveal_cards.push(card_name.to_string());
    }
}
}

Implementation Notes

  1. All random operations should be abstracted through an RngService to allow for deterministic testing.
  2. The RngService should be injectable and replaceable with a mock version for tests.
  3. Test cases should validate both mechanical correctness and expected probabilities.
  4. The UI should clearly communicate random events to players, with appropriate animations.

Future Considerations

  1. Testing network lag effects on synchronization of random events in multiplayer games
  2. Validating fairness of random number generation over large sample sizes
  3. Implementing proper security measures to prevent cheating in networked games

Player Mechanics

This section covers player-specific mechanics in the Commander format implementation.

Contents

The player mechanics section defines how players interact within the Commander format, including:

  • Starting with 40 life (compared to 20 in standard Magic)
  • Commander damage tracking (21+ combat damage from a single commander causes a loss)
  • Turn order and priority management in multiplayer
  • Special multiplayer interactions like voting, monarch status, and political deals
  • Player elimination and handling of player-owned objects after elimination

These mechanics are essential for correctly implementing the multiplayer aspects of Commander, especially with games supporting between 2 and 13 players.

Life Total Management in Commander

This document covers the implementation of life total management in the Commander format within Rummage.

Commander Life Total Rules

In the Commander format, players have the following life total rules:

  1. Starting life total is 40 (compared to 20 in standard Magic)
  2. A player loses if their life total is 0 or less
  3. A player can gain life above their starting total with no upper limit
  4. Life totals are tracked for each player throughout the game
  5. There is no life loss from drawing from an empty library (unlike in standard Magic)

Additionally, Commander has a unique "Commander damage" rule:

  • A player loses if they've been dealt 21 or more combat damage by a single commander

Implementation

Life Total Component

#![allow(unused)]
fn main() {
/// Component for tracking player life totals
#[derive(Component, Debug, Clone, Reflect)]
pub struct LifeTotal {
    /// Current life value
    pub current: i32,
    /// Starting life value
    pub starting: i32,
    /// Damage taken from each commander (entity ID -> damage amount)
    pub commander_damage: HashMap<Entity, i32>,
}

impl Default for LifeTotal {
    fn default() -> Self {
        Self {
            current: 40, // Commander starts at 40 life
            starting: 40,
            commander_damage: HashMap::new(),
        }
    }
}
}

Life Change Events

#![allow(unused)]
fn main() {
#[derive(Event, Debug, Clone)]
pub enum LifeChangeEvent {
    Gain(Entity, i32),     // (Player entity, amount)
    Loss(Entity, i32),     // (Player entity, amount)
    Set(Entity, i32),      // (Player entity, new value)
    CommanderDamage {
        target: Entity,    // Target player
        source: Entity,    // Commander entity
        amount: i32,       // Damage amount
    },
}
}

Life Total System

#![allow(unused)]
fn main() {
/// System that handles life total changes
pub fn handle_life_changes(
    mut players: Query<(Entity, &mut LifeTotal)>,
    mut life_events: EventReader<LifeChangeEvent>,
    mut game_events: EventWriter<GameEvent>,
) {
    let mut changed_players = HashSet::new();
    
    // Process all life change events
    for event in life_events.read() {
        match event {
            LifeChangeEvent::Gain(entity, amount) => {
                if let Ok((_, mut life)) = players.get_mut(*entity) {
                    life.current += amount;
                    changed_players.insert(*entity);
                }
            },
            LifeChangeEvent::Loss(entity, amount) => {
                if let Ok((_, mut life)) = players.get_mut(*entity) {
                    life.current -= amount;
                    changed_players.insert(*entity);
                }
            },
            LifeChangeEvent::Set(entity, value) => {
                if let Ok((_, mut life)) = players.get_mut(*entity) {
                    life.current = *value;
                    changed_players.insert(*entity);
                }
            },
            LifeChangeEvent::CommanderDamage { target, source, amount } => {
                if let Ok((_, mut life)) = players.get_mut(*target) {
                    let current_damage = life.commander_damage.entry(*source).or_insert(0);
                    *current_damage += amount;
                    life.current -= amount;
                    changed_players.insert(*target);
                }
            }
        }
    }
    
    // Check for game loss conditions
    for entity in changed_players {
        if let Ok((player_entity, life)) = players.get(entity) {
            // Check for zero or less life
            if life.current <= 0 {
                game_events.send(GameEvent::PlayerLost {
                    player: player_entity,
                    reason: LossReason::LifeTotal,
                });
            }
            
            // Check for commander damage
            for (cmdr, damage) in &life.commander_damage {
                if *damage >= 21 {
                    game_events.send(GameEvent::PlayerLost {
                        player: player_entity,
                        reason: LossReason::CommanderDamage(*cmdr),
                    });
                    break;
                }
            }
        }
    }
}
}

Life Gain/Loss Display

In the UI, life total changes are displayed with:

  1. Animated counters that show the direction and amount of life change
  2. Color-coded visual feedback (green for gain, red for loss)
  3. Persistent life total display for all players
  4. Visual warning when a player is at low life
  5. Commander damage trackers for each opponent's commander

Life Total Interactions

Various cards and effects can interact with life totals:

  1. Life gain/loss effects: Direct modification of life totals
  2. Life total setting effects: Cards that set life to a specific value
  3. Life swapping effects: Cards that exchange life totals between players
  4. Damage redirection: Effects that redirect damage from one player to another
  5. Damage prevention: Effects that prevent damage that would be dealt

Testing Life Total Management

We test life total functionality with:

  1. Unit tests: Verifying the baseline functionality
  2. Integration tests: Testing interactions with damage effects
  3. Edge case tests: Testing boundary conditions (very high/low life totals)
  4. Visual tests: Verifying the UI correctly displays life changes

Example Test

#![allow(unused)]
fn main() {
#[test]
fn test_commander_damage_loss() {
    // Create a test app with required systems
    let mut app = App::new();
    app.add_systems(Update, handle_life_changes)
        .add_event::<LifeChangeEvent>()
        .add_event::<GameEvent>();
        
    // Create player entities
    let player = app.world.spawn(LifeTotal::default()).id();
    let commander = app.world.spawn_empty().id();
    
    // Deal 21 commander damage
    app.world.send_event(LifeChangeEvent::CommanderDamage {
        target: player,
        source: commander,
        amount: 21,
    });
    
    // Run systems
    app.update();
    
    // Check if loss event was sent
    let events = app.world.resource::<Events<GameEvent>>();
    let mut reader = events.get_reader();
    let mut found_loss = false;
    
    for event in reader.read(&events) {
        if let GameEvent::PlayerLost { player: p, reason: LossReason::CommanderDamage(cmdr) } = event {
            if *p == player && *cmdr == commander {
                found_loss = true;
                break;
            }
        }
    }
    
    assert!(found_loss, "Player should lose to commander damage");
}
}

Summary

Life total management in Commander is implemented with a flexible system that:

  1. Correctly applies the Commander-specific starting life total of 40
  2. Tracks commander damage separately from regular life changes
  3. Implements all standard and Commander-specific loss conditions
  4. Provides clear visual feedback through the UI
  5. Supports all card interactions with life totals

Commander Tax

This document explains the implementation of the Commander Tax mechanic in the Rummage game engine.

Overview

Commander Tax is a core rule of the Commander format that increases the cost to cast your commander from the command zone by {2} for each previous time you've cast it from the command zone during the game.

The rule ensures that:

  1. Players cannot repeatedly abuse their commander's abilities by letting it die and recasting it
  2. The game becomes progressively more challenging as players need to invest more mana into recasting their commander
  3. Decks need to consider their commander's mana value when building their strategy

Formula

The cost to cast a commander is calculated as:

Total Cost = Base Cost + (2 × Number of Previous Casts)

For example:

  • First cast: Base cost
  • Second cast: Base cost + {2}
  • Third cast: Base cost + {4}
  • Fourth cast: Base cost + {6}

Implementation

Commander Component

#![allow(unused)]
fn main() {
/// Component for commanders
#[derive(Component, Debug, Clone, Reflect)]
pub struct Commander {
    /// Entity ID of the player who owns this commander
    pub owner: Entity,
    /// Number of times this commander has been cast from the command zone
    pub cast_count: u32,
    /// Whether this commander is in the command zone
    pub in_command_zone: bool,
}

impl Default for Commander {
    fn default() -> Self {
        Self {
            owner: Entity::PLACEHOLDER,
            cast_count: 0,
            in_command_zone: true,
        }
    }
}
}

Commander Tax Calculation

#![allow(unused)]
fn main() {
/// Calculates the total cost to cast a commander
pub fn calculate_commander_cost(
    base_cost: Mana,
    commander: &Commander,
) -> Mana {
    // Apply commander tax: 2 generic mana for each previous cast
    let tax_amount = commander.cast_count * 2;
    
    // Create a new mana cost with additional generic mana
    let mut total_cost = base_cost.clone();
    total_cost.colorless += tax_amount;
    
    total_cost
}
}

Casting System

#![allow(unused)]
fn main() {
/// System to handle commander casting
pub fn handle_commander_casting(
    mut commands: Commands,
    mut commanders: Query<(Entity, &mut Commander, &CardCost)>,
    mut cast_events: EventReader<CastCommanderEvent>,
    mut mana_events: EventWriter<ManaCostEvent>,
) {
    for event in cast_events.read() {
        if let Ok((entity, mut commander, cost)) = commanders.get_mut(event.commander) {
            // Calculate total cost with commander tax
            let total_cost = calculate_commander_cost(cost.mana.clone(), &commander);
            
            // Send event to check if player can pay the cost
            mana_events.send(ManaCostEvent {
                player: commander.owner,
                source: entity,
                cost: total_cost,
                on_paid: CastEffect::Commander { entity },
            });
            
            // Increment cast count for next time
            if event.successful {
                commander.cast_count += 1;
                commander.in_command_zone = false;
            }
        }
    }
}
}

Commander Tax Tracking

The UI displays the current commander tax for each player's commander:

  1. Current cast count is shown next to the commander in the command zone
  2. The additional cost is displayed when hovering over the commander in the command zone
  3. The total cost (base + tax) is shown when attempting to cast the commander

Special Interactions

Tax Reduction Effects

Some cards can reduce the commander tax:

#![allow(unused)]
fn main() {
/// Component for effects that reduce commander tax
#[derive(Component, Debug, Clone, Reflect)]
pub struct CommanderTaxReduction {
    /// Amount to reduce the tax by
    pub amount: u32,
    /// Whether this applies to all commanders or specific ones
    pub targets: CommanderTaxReductionTarget,
}

/// Different types of tax reduction targets
#[derive(Debug, Clone, Reflect)]
pub enum CommanderTaxReductionTarget {
    /// Applies to all of your commanders
    AllOwn,
    /// Applies to all commanders (including opponents')
    All,
    /// Applies to specific commanders
    Specific(Vec<Entity>),
}
}

Partner Commanders

Partner commanders track their tax separately:

#![allow(unused)]
fn main() {
/// When checking if a player has a partner commander
pub fn handle_partner_commander_tax(
    partners: Query<(Entity, &Commander, &Partner)>,
    // ... other parameters
) {
    // Each partner tracks its own commander tax separately
    // ...
}
}

Testing

Test Cases

We test the commander tax mechanics with:

  1. Basic Tax Progression: Verify tax increases correctly with each cast
  2. Tax After Zone Changes: Ensure tax only increases when cast from command zone
  3. Tax Reduction Effects: Test cards that reduce commander tax
  4. Partner Commander Interaction: Verify partners track tax separately

Example Test

#![allow(unused)]
fn main() {
#[test]
fn test_commander_tax_progression() {
    // Set up test environment
    let mut app = App::new();
    app.add_systems(Update, handle_commander_casting)
        .add_event::<CastCommanderEvent>()
        .add_event::<ManaCostEvent>();
    
    // Create a player and commander
    let player = app.world.spawn_empty().id();
    let base_cost = Mana::new(2, 1, 0, 0, 0, 0); // 2G
    
    let commander = app.world.spawn((
        Commander {
            owner: player,
            cast_count: 0,
            in_command_zone: true,
        },
        CardCost { mana: base_cost.clone() },
    )).id();
    
    // First cast (should be base cost)
    app.world.send_event(CastCommanderEvent {
        commander,
        successful: true,
    });
    app.update();
    
    // Check commander was updated
    let cmdr = app.world.get::<Commander>(commander).unwrap();
    assert_eq!(cmdr.cast_count, 1);
    
    // Second cast (should be base cost + {2})
    app.world.send_event(CastCommanderEvent {
        commander,
        successful: true,
    });
    app.update();
    
    // Verify tax increased
    let cmdr = app.world.get::<Commander>(commander).unwrap();
    assert_eq!(cmdr.cast_count, 2);
    
    // Verify correct cost calculation
    let cmdr = app.world.get::<Commander>(commander).unwrap();
    let total_cost = calculate_commander_cost(base_cost, cmdr);
    assert_eq!(total_cost.colorless, 6); // Base 2 + 4 tax
    assert_eq!(total_cost.green, 1);     // Colored cost unchanged
}
}

Summary

The Commander Tax mechanic in Rummage is implemented as a dynamic cost increase system that:

  1. Tracks cast count per commander
  2. Applies the correct tax formula
  3. Supports complex interactions like cost reduction effects
  4. Handles partner commanders appropriately
  5. Provides clear UI feedback on current tax amounts

Color Identity in Commander

This document explains how color identity is implemented in the Rummage game engine for the Commander format.

Color Identity Rules

In Commander, a card's color identity determines which decks it can be included in. The color identity rules are:

  1. A card's color identity includes all colored mana symbols that appear:

    • In its mana cost
    • In its rules text
    • On either face of a double-faced card
    • On all parts of a split or adventure card
  2. Color identity is represented by the colors: White, Blue, Black, Red, and Green

  3. A card's color identity can include colors even if the card itself is not those colors

  4. Cards in a commander deck must only use colors within the color identity of the deck's commander

  5. Basic lands with intrinsic mana abilities are only legal in decks where all their produced colors are in the commander's color identity

Component Implementation

#![allow(unused)]
fn main() {
/// Component representing a card's color identity
#[derive(Component, Debug, Clone, Reflect)]
pub struct ColorIdentity {
    /// White in color identity
    pub white: bool,
    /// Blue in color identity
    pub blue: bool,
    /// Black in color identity
    pub black: bool,
    /// Red in color identity
    pub red: bool,
    /// Green in color identity
    pub green: bool,
}

impl ColorIdentity {
    /// Creates a new color identity from a set of colors
    pub fn new(white: bool, blue: bool, black: bool, red: bool, green: bool) -> Self {
        Self {
            white,
            blue,
            black,
            red,
            green,
        }
    }
    
    /// Creates a colorless identity
    pub fn colorless() -> Self {
        Self::new(false, false, false, false, false)
    }
    
    /// Checks if this color identity contains another
    pub fn contains(&self, other: &ColorIdentity) -> bool {
        (!other.white || self.white) &&
        (!other.blue || self.blue) &&
        (!other.black || self.black) &&
        (!other.red || self.red) &&
        (!other.green || self.green)
    }
    
    /// Gets the colors as an array of booleans [W, U, B, R, G]
    pub fn as_array(&self) -> [bool; 5] {
        [self.white, self.blue, self.black, self.red, self.green]
    }
    
    /// Gets the number of colors in this identity
    pub fn color_count(&self) -> usize {
        self.as_array().iter().filter(|&&color| color).count()
    }
    
    /// Checks if this is a colorless identity
    pub fn is_colorless(&self) -> bool {
        self.color_count() == 0
    }
}
}

Calculating Color Identity

The system for calculating a card's color identity needs to analyze all text and symbols on the card:

#![allow(unused)]
fn main() {
/// Calculates a card's color identity from its various components
pub fn calculate_color_identity(
    mana_cost: &Mana,
    rules_text: &str,
    card_type: &CardTypes,
) -> ColorIdentity {
    let mut identity = ColorIdentity::colorless();
    
    // Check mana cost
    identity.white = identity.white || mana_cost.white > 0;
    identity.blue = identity.blue || mana_cost.blue > 0;
    identity.black = identity.black || mana_cost.black > 0;
    identity.red = identity.red || mana_cost.red > 0;
    identity.green = identity.green || mana_cost.green > 0;
    
    // Check rules text for mana symbols using regex
    let re = Regex::new(r"\{([WUBRG])/([WUBRG])\}|\{([WUBRG])\}").unwrap();
    for cap in re.captures_iter(rules_text) {
        if let Some(hybrid_1) = cap.get(1) {
            match hybrid_1.as_str() {
                "W" => identity.white = true,
                "U" => identity.blue = true,
                "B" => identity.black = true,
                "R" => identity.red = true,
                "G" => identity.green = true,
                _ => {}
            }
        }
        if let Some(hybrid_2) = cap.get(2) {
            match hybrid_2.as_str() {
                "W" => identity.white = true,
                "U" => identity.blue = true,
                "B" => identity.black = true,
                "R" => identity.red = true,
                "G" => identity.green = true,
                _ => {}
            }
        }
        if let Some(single) = cap.get(3) {
            match single.as_str() {
                "W" => identity.white = true,
                "U" => identity.blue = true,
                "B" => identity.black = true,
                "R" => identity.red = true,
                "G" => identity.green = true,
                _ => {}
            }
        }
    }
    
    // Check for color indicators
    if let Some(colors) = card_type.get_color_indicator() {
        for color in colors {
            match color {
                Color::White => identity.white = true,
                Color::Blue => identity.blue = true,
                Color::Black => identity.black = true,
                Color::Red => identity.red = true,
                Color::Green => identity.green = true,
                _ => {}
            }
        }
    }
    
    identity
}
}

Deck Validation

Deck validation ensures that all cards in a deck match the commander's color identity:

#![allow(unused)]
fn main() {
/// System to validate deck color identity during deck construction
pub fn validate_deck_color_identity(
    commanders: Query<(&Commander, &ColorIdentity)>,
    cards: Query<(Entity, &InDeck, &ColorIdentity)>,
    mut validation_events: EventWriter<DeckValidationEvent>,
) {
    // For each deck
    let decks = cards.iter()
        .map(|(_, in_deck, _)| in_deck.deck)
        .collect::<HashSet<_>>();
    
    for deck in decks {
        // Find the commander(s) for this deck
        let deck_commanders = commanders.iter()
            .filter(|(cmdr, _)| cmdr.deck == deck)
            .collect::<Vec<_>>();
        
        if deck_commanders.is_empty() {
            validation_events.send(DeckValidationEvent::Invalid {
                deck,
                reason: "No commander found for deck".to_string(),
            });
            continue;
        }
        
        // Calculate the combined color identity for all commanders (for partner support)
        let mut combined_identity = ColorIdentity::colorless();
        for (_, identity) in deck_commanders.iter() {
            combined_identity.white |= identity.white;
            combined_identity.blue |= identity.blue;
            combined_identity.black |= identity.black;
            combined_identity.red |= identity.red;
            combined_identity.green |= identity.green;
        }
        
        // Check that all cards match the commander's color identity
        for (entity, in_deck, card_identity) in cards.iter() {
            if in_deck.deck != deck {
                continue;
            }
            
            if !combined_identity.contains(card_identity) {
                validation_events.send(DeckValidationEvent::InvalidCard {
                    deck,
                    card: entity,
                    reason: format!("Card color identity outside of commander's color identity"),
                });
            }
        }
    }
}
}

Special Cases

Partner Commanders

When using partner commanders, the deck's color identity is the union of both commanders' color identities:

#![allow(unused)]
fn main() {
/// System to handle partner commanders' combined color identity
pub fn handle_partner_color_identity(
    partners: Query<(Entity, &Partner, &ColorIdentity)>,
    decks: Query<&Deck>,
) {
    // Group partners by deck
    let mut deck_partners = HashMap::new();
    for (entity, partner, identity) in partners.iter() {
        deck_partners.entry(partner.deck)
            .or_insert_with(Vec::new)
            .push((entity, identity));
    }
    
    // For each deck with partners
    for (deck_entity, partners) in deck_partners.iter() {
        if let Ok(deck) = decks.get(*deck_entity) {
            // Calculate combined identity
            let mut combined = ColorIdentity::colorless();
            for (_, identity) in partners.iter() {
                combined.white |= identity.white;
                combined.blue |= identity.blue;
                combined.black |= identity.black;
                combined.red |= identity.red;
                combined.green |= identity.green;
            }
            
            // Store combined identity for deck validation
            // ...
        }
    }
}
}

Five-Color Commanders

Five-color commanders like "The Ur-Dragon" allow any card in the deck:

#![allow(unused)]
fn main() {
impl ColorIdentity {
    /// Checks if this is a five-color identity
    pub fn is_five_color(&self) -> bool {
        self.white && self.blue && self.black && self.red && self.green
    }
}
}

UI Representation

Color identity is visually represented in the UI:

  1. The commander's color identity is displayed in the command zone
  2. During deck construction, cards that don't match the commander's color identity are highlighted
  3. Card browser filters can be set to only show cards matching the commander's color identity
  4. Color identity is shown as colored pips in card displays

Testing

Example Tests

#![allow(unused)]
fn main() {
#[test]
fn test_color_identity_calculation() {
    // Test basic mana cost identity
    let cost = Mana::new(0, 1, 1, 0, 0, 0); // WU
    let identity = calculate_color_identity(&cost, "", &CardTypes::default());
    assert!(identity.white);
    assert!(identity.blue);
    assert!(!identity.black);
    assert!(!identity.red);
    assert!(!identity.green);
    
    // Test rules text identity
    let cost = Mana::new(0, 0, 0, 0, 0, 0); // Colorless
    let text = "Add {R} or {G} to your mana pool.";
    let identity = calculate_color_identity(&cost, text, &CardTypes::default());
    assert!(!identity.white);
    assert!(!identity.blue);
    assert!(!identity.black);
    assert!(identity.red);
    assert!(identity.green);
    
    // Test hybrid mana
    let cost = Mana::new(0, 0, 0, 0, 0, 0); // Colorless
    let text = "This spell costs {W/B} less to cast.";
    let identity = calculate_color_identity(&cost, text, &CardTypes::default());
    assert!(identity.white);
    assert!(!identity.blue);
    assert!(identity.black);
    assert!(!identity.red);
    assert!(!identity.green);
}

#[test]
fn test_deck_validation() {
    // Set up a test environment
    let mut app = App::new();
    app.add_systems(Update, validate_deck_color_identity)
        .add_event::<DeckValidationEvent>();
    
    // Create a Simic (Green-Blue) commander
    let commander_entity = app.world.spawn((
        Commander { deck: Entity::from_raw(1), ..Default::default() },
        ColorIdentity::new(false, true, false, false, true), // Blue-Green
    )).id();
    
    // Create a deck with the commander and some cards
    let deck_entity = Entity::from_raw(1);
    
    // Valid cards
    let valid_card1 = app.world.spawn((
        InDeck { deck: deck_entity },
        ColorIdentity::new(false, true, false, false, false), // Blue only
    )).id();
    
    let valid_card2 = app.world.spawn((
        InDeck { deck: deck_entity },
        ColorIdentity::new(false, false, false, false, true), // Green only
    )).id();
    
    // Invalid card (contains red)
    let invalid_card = app.world.spawn((
        InDeck { deck: deck_entity },
        ColorIdentity::new(false, true, false, true, true), // Blue-Red-Green
    )).id();
    
    // Run validation
    app.update();
    
    // Check validation results
    let events = app.world.resource::<Events<DeckValidationEvent>>();
    let mut reader = events.get_reader();
    
    let mut invalid_cards = Vec::new();
    for event in reader.read(&events) {
        if let DeckValidationEvent::InvalidCard { card, .. } = event {
            invalid_cards.push(*card);
        }
    }
    
    assert_eq!(invalid_cards.len(), 1);
    assert_eq!(invalid_cards[0], invalid_card);
}
}

Summary

Color identity in Commander is implemented as a comprehensive system that:

  1. Correctly calculates color identity from all relevant card components
  2. Enforces deck construction rules based on the commander's color identity
  3. Supports special cases like partner commanders and colorless commanders
  4. Provides clear visual feedback in the UI
  5. Is thoroughly tested with both unit and integration tests

Commander-Specific Zone Mechanics

Overview

This section covers the implementation of Commander-specific zone mechanics in the Rummage game engine. For core zone mechanics that apply to all formats, see the MTG Core Zones documentation.

Commander Zone Extensions

Commander extends the basic MTG zone system by:

  1. Adding the Command Zone - A special zone where commanders begin the game
  2. Modifying Zone Transition Rules - Commanders can optionally move to the command zone instead of other zones
  3. Introducing Commander Tax - Additional mana cost for casting commanders from the command zone

Contents

  • Command Zone - Implementation of the command zone and commander-specific mechanics
  • Zone Transitions - Special rules for commander movement between zones
  • Zone Management - Extended zone implementation for Commander games

Key Commander Zone Mechanics

  • Commander Placement: Commanders start in the command zone before the game begins
  • Zone Replacement: When a commander would be put into a library, hand, graveyard, or exile, its owner may choose to put it into the command zone instead
  • Commander Tax: Each time a commander is cast from the command zone, it costs an additional {2} for each previous time it has been cast from the command zone
  • Partner Commanders: Special handling for decks with two commanders (Partner mechanic)
  • Commander Backgrounds: Implementation of the Background mechanic for certain commanders

These Commander-specific zone mechanics build upon the core zone system to enable the unique gameplay experience of the Commander format.


Next: Command Zone

Command Zone Management

Overview

The Command Zone is a unique game zone central to the Commander format. This module manages the Command Zone, Commander card movement between zones, and the special rules surrounding Commander cards. It integrates with the zone management and player systems to provide a complete implementation of Commander-specific mechanics according to the official Magic: The Gathering Comprehensive Rules section 903.

Core Components

Command Zone Structure

#![allow(unused)]
fn main() {
#[derive(Resource)]
pub struct CommandZoneManager {
    // Maps player entity to their commander entities in the command zone
    pub command_zones: HashMap<Entity, Vec<Entity>>,
    
    // Tracks whether commanders are in the command zone or elsewhere
    pub commander_zone_status: HashMap<Entity, CommanderZoneLocation>,
    
    // Tracks the number of times each commander has been cast from the command zone
    pub cast_count: HashMap<Entity, u32>,
    
    // Tracks commanders that died/were exiled this turn (for state-based actions)
    pub died_this_turn: HashSet<Entity>,
    pub exiled_this_turn: HashSet<Entity>,
    
    // Track partner commanders and backgrounds
    pub commander_partnerships: HashMap<Entity, Entity>,
    pub backgrounds: HashMap<Entity, Entity>,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CommanderZoneLocation {
    CommandZone,
    Battlefield,
    Graveyard,
    Exile,
    Library,
    Hand,
    Stack,
    Limbo, // Transitional state
}
}

Commander Components

#![allow(unused)]
fn main() {
#[derive(Component)]
pub struct Commander {
    pub owner: Entity,
    pub partner: Option<Entity>,
    pub background: Option<Entity>,
    pub color_identity: ColorIdentity,
}

#[derive(Component)]
pub struct CommanderCastable {
    pub base_cost: ManaCost,
    pub current_tax: u32,
}
}

Key Systems

Command Zone Initialization

#![allow(unused)]
fn main() {
pub fn initialize_command_zone(
    mut commands: Commands,
    mut command_zone: ResMut<CommandZoneManager>,
    players: Query<Entity, With<Player>>,
    commanders: Query<(Entity, &Commander)>,
) {
    // Create empty command zone entries for each player
    for player in players.iter() {
        command_zone.command_zones.insert(player, Vec::new());
    }
    
    // Place all commanders in their owners' command zones
    for (entity, commander) in commanders.iter() {
        if let Some(zone_list) = command_zone.command_zones.get_mut(&commander.owner) {
            zone_list.push(entity);
        }
        
        // Set initial zone status
        command_zone.commander_zone_status.insert(entity, CommanderZoneLocation::CommandZone);
        
        // Initialize cast count for commander tax
        command_zone.cast_count.insert(entity, 0);
        
        // Add castable component with initial tax of zero
        if let Ok(card) = commanders.get_component::<Card>(entity) {
            commands.entity(entity).insert(CommanderCastable {
                base_cost: card.cost.clone(),
                current_tax: 0,
            });
        }
    }
    
    // Set up partner relationships
    for (entity, commander) in commanders.iter() {
        if let Some(partner) = commander.partner {
            command_zone.commander_partnerships.insert(entity, partner);
        }
        
        if let Some(background) = commander.background {
            command_zone.backgrounds.insert(entity, background);
        }
    }
}
}

Commander Casting System

#![allow(unused)]
fn main() {
pub fn handle_commander_cast(
    mut commands: Commands,
    mut command_zone: ResMut<CommandZoneManager>,
    mut cast_events: EventReader<CastFromCommandZoneEvent>,
    mut castable: Query<&mut CommanderCastable>,
) {
    for event in cast_events.read() {
        let commander_entity = event.commander;
        
        // Update zone status
        command_zone.commander_zone_status.insert(commander_entity, CommanderZoneLocation::Stack);
        
        // Increment cast count for commander tax
        if let Some(count) = command_zone.cast_count.get_mut(&commander_entity) {
            *count += 1;
            
            // Update tax amount for next cast
            if let Ok(mut commander_castable) = castable.get_mut(commander_entity) {
                commander_castable.current_tax = *count * 2; // 2 mana per previous cast
            }
        }
        
        // Remove from command zone list
        if let Some(player_zone) = command_zone.command_zones.get_mut(&event.player) {
            if let Some(pos) = player_zone.iter().position(|&c| c == commander_entity) {
                player_zone.swap_remove(pos);
            }
        }
    }
}
}

Zone Transition Handler

#![allow(unused)]
fn main() {
pub fn handle_commander_zone_transitions(
    mut commands: Commands,
    mut command_zone: ResMut<CommandZoneManager>,
    mut zone_events: EventReader<ZoneTransitionEvent>,
    mut choice_events: EventWriter<CommanderZoneChoiceEvent>,
) {
    for event in zone_events.read() {
        // Only process events for commander entities
        if !command_zone.commander_zone_status.contains_key(&event.entity) {
            continue;
        }
        
        let destination = match event.destination {
            Zone::Graveyard => CommanderZoneLocation::Graveyard,
            Zone::Exile => CommanderZoneLocation::Exile,
            Zone::Library => CommanderZoneLocation::Library,
            Zone::Hand => CommanderZoneLocation::Hand,
            Zone::Battlefield => CommanderZoneLocation::Battlefield,
            Zone::Stack => CommanderZoneLocation::Stack,
            // Handle other zones...
            _ => continue,
        };
        
        // Record death/exile for state-based actions
        if destination == CommanderZoneLocation::Graveyard {
            command_zone.died_this_turn.insert(event.entity);
        } else if destination == CommanderZoneLocation::Exile {
            command_zone.exiled_this_turn.insert(event.entity);
        }
        
        // Update commander location
        command_zone.commander_zone_status.insert(event.entity, destination);
        
        // If moving to graveyard, exile, library, or hand, offer replacement to command zone
        if matches!(destination, 
            CommanderZoneLocation::Graveyard | 
            CommanderZoneLocation::Exile | 
            CommanderZoneLocation::Library | 
            CommanderZoneLocation::Hand) 
        {
            // Find owner
            let owner = commanders.get_component::<Commander>(event.entity)
                .map(|c| c.owner)
                .unwrap_or(event.controller);
                
            // Send choice event to owner
            choice_events.send(CommanderZoneChoiceEvent {
                commander: event.entity,
                owner,
                from_zone: destination,
            });
        }
    }
}
}

Command Zone API

The Command Zone module provides a public API for other modules to interact with commander-specific functionality:

#![allow(unused)]
fn main() {
impl CommandZoneManager {
    // Get the current tax for a commander
    pub fn get_commander_tax(&self, commander: Entity) -> u32 {
        self.cast_count.get(&commander).copied().unwrap_or(0) * 2
    }
    
    // Check if an entity is a commander
    pub fn is_commander(&self, entity: Entity) -> bool {
        self.commander_zone_status.contains_key(&entity)
    }
    
    // Move a commander to the command zone
    pub fn move_to_command_zone(&mut self, commander: Entity, owner: Entity) {
        // Update status
        self.commander_zone_status.insert(commander, CommanderZoneLocation::CommandZone);
        
        // Add to command zone list
        if let Some(zone) = self.command_zones.get_mut(&owner) {
            if !zone.contains(&commander) {
                zone.push(commander);
            }
        }
    }
    
    // Get all commanders for a player
    pub fn get_player_commanders(&self, player: Entity) -> Vec<Entity> {
        self.command_zones.get(&player)
            .cloned()
            .unwrap_or_default()
    }
}
}

The Command Zone management module is central to the Commander format, as it implements the unique rules that define the format, including the command zone, commander casting with tax, and zone transition replacements.

Commander Exile Zone

This section documents the implementation of the Exile Zone in the Commander format.

Overview

The Exile Zone in Magic: The Gathering represents a special area where cards are removed from the game. In Commander, there are specific interactions between the Exile Zone and the Command Zone that merit special attention.

Implementation Details

Exile Zone Component

#![allow(unused)]
fn main() {
#[derive(Component)]
pub struct ExileZone {
    pub contents: Vec<Entity>,
}
}

Commander-Specific Exile Interactions

When a commander would be exiled, the owner can choose to move it to the Command Zone instead. This is implemented through a player choice event:

#![allow(unused)]
fn main() {
fn handle_commander_exile(
    mut commands: Commands,
    mut exile_events: EventReader<ExileEvent>,
    commanders: Query<(Entity, &Commander)>,
    mut choice_events: EventWriter<PlayerChoiceEvent>,
) {
    for event in exile_events.iter() {
        if let Ok((entity, commander)) = commanders.get(event.card) {
            // Give player the choice to move to command zone instead
            choice_events.send(PlayerChoiceEvent {
                player: commander.owner,
                choices: vec![
                    PlayerChoice::MoveToExile,
                    PlayerChoice::MoveToCommandZone,
                ],
                context: ChoiceContext::CommanderExile { commander: entity },
            });
        }
    }
}
}

Exile-Based Effects

Many Commander cards interact with the Exile Zone in specific ways:

  1. Temporary Exile: Effects that exile cards until certain conditions are met
  2. Exile as Resource: Effects that use exiled cards for special abilities
  3. Commander Reprocessing: When commanders return from exile, they reset any temporary effects

Testing

The Exile Zone implementation is tested with these scenarios:

#![allow(unused)]
fn main() {
#[test]
fn test_commander_exile_choice() {
    // Test setup
    let mut app = App::new();
    app.add_plugins(CommanderPlugin);
    
    // Create test entities
    let player = app.world.spawn_empty().id();
    let commander = app.world.spawn((
        Commander { owner: player, cast_count: 0 },
        Card::default(),
    )).id();
    
    // Trigger exile event
    app.world.resource_mut::<Events<ExileEvent>>().send(ExileEvent {
        card: commander,
        source: None,
    });
    
    // Run systems
    app.update();
    
    // Verify player received choice
    let choices = app.world.resource::<Events<PlayerChoiceEvent>>()
        .get_reader()
        .iter(app.world.resource::<Events<PlayerChoiceEvent>>())
        .collect::<Vec<_>>();
    
    assert!(!choices.is_empty());
    // Additional assertions...
}
}

Integration with Other Zones

The Exile Zone interacts with other zones in specific ways:

  • Command Zone: Commander replacement effect
  • Battlefield: "Until end of turn" exile effects
  • Graveyard: "Exile from graveyard" effects common in Commander

Next Steps


This page is part of the Game Zones documentation for the Commander format.

Commander Zone Transitions

Overview

Zone transitions are a critical aspect of the Commander format, particularly with the special replacement effect that allows commanders to return to the Command Zone instead of going to certain other zones. This document details the implementation of these zone transition rules according to Magic: The Gathering Comprehensive Rules section 903.9.

Commander Zone Transition Rules

According to the rules:

  1. If a commander would be exiled or put into a hand, graveyard, or library from anywhere, its owner may choose to put it into the command zone instead. This is a replacement effect.
  2. The choice is made as the zone change would occur.
  3. If a commander is moved directly to the command zone in this way, its last known information is used to determine what happened to it.

Implementation Components

Zone Transition Events

#![allow(unused)]
fn main() {
#[derive(Event)]
pub struct ZoneTransitionEvent {
    pub entity: Entity,
    pub controller: Entity,
    pub source: Zone,
    pub destination: Zone,
    pub reason: ZoneChangeReason,
}

#[derive(Event)]
pub struct CommanderZoneChoiceEvent {
    pub commander: Entity,
    pub owner: Entity,
    pub from_zone: CommanderZoneLocation,
}

#[derive(Event)]
pub struct CommanderZoneChoiceResponseEvent {
    pub commander: Entity,
    pub owner: Entity,
    pub choose_command_zone: bool,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ZoneChangeReason {
    Cast,
    Resolve,
    PutOntoBattlefield,
    Destroy,
    Sacrifice,
    Exile,
    ReturnToHand,
    PutIntoGraveyard,
    PutIntoLibrary,
    PhasingOut,
    PhasingIn,
    StateBasedAction,
    Replacement,
    Other,
}
}

Zone Transition System

#![allow(unused)]
fn main() {
pub fn handle_commander_zone_choice_responses(
    mut commands: Commands,
    mut command_zone: ResMut<CommandZoneManager>,
    mut response_events: EventReader<CommanderZoneChoiceResponseEvent>,
    mut state_actions: EventWriter<StateBasedActionEvent>,
) {
    for event in response_events.read() {
        if event.choose_command_zone {
            // Update commander location to Command Zone
            command_zone.commander_zone_status.insert(event.commander, CommanderZoneLocation::CommandZone);
            
            // Add to command zone list if not already there
            if let Some(zone) = command_zone.command_zones.get_mut(&event.owner) {
                if !zone.contains(&event.commander) {
                    zone.push(event.commander);
                }
            }
            
            // Trigger any state-based actions that might care about this
            state_actions.send(StateBasedActionEvent::CommanderZoneChanged(event.commander));
        }
    }
}
}

Zone Transition Logic

The zone transition system follows this logical flow:

  1. A card is about to change zones (triggered by a game event)
  2. The system checks if the card is a commander
  3. If it is, and the destination is hand, graveyard, library, or exile: a. A choice event is sent to the owner b. The game may need to wait for user input (or AI decision) c. The owner chooses whether to redirect to command zone
  4. Based on the choice, the card either goes to the original destination or the command zone
  5. State-based actions are triggered to handle any consequences

Zone Transition Diagram

                   +-----------------+
                   | Zone Change     |
                   | Triggered       |
                   +--------+--------+
                            |
                   +--------v--------+
                   | Is it a         |
              No   | Commander?      |   Yes
          +--------+                 +--------+
          |        +-----------------+        |
          |                                   |
+---------v---------+               +---------v---------+
| Normal Zone       |               | Destination GY,   |   No
| Change Processing |               | Hand, Library,    +--------+
+-------------------+               | or Exile?         |        |
                                    +---------+---------+        |
                                              |                  |
                                              | Yes              |
                                    +---------v---------+  +-----v------+
                                    | Send Choice Event |  | Normal Zone|
                                    | to Owner          |  | Change     |
                                    +---------+---------+  +------------+
                                              |
                             +-----------+    |    +------------+
                             |           |    |    |            |
                      +------v------+    |    |  +-v-----------v--+
                      | Choose      |    |    |  | Choose Original |
                      | Command Zone|<---+----+->| Destination     |
                      +------+------+         |  +--------+--------+
                             |                |           |
               +-------------v-------------+  |  +--------v---------+
               | Move to Command Zone      |  |  | Move to Original |
               | Update Status             |  |  | Destination      |
               | Trigger State-Based       |  |  | Update Status    |
               | Actions                   |  |  | Trigger Effects  |
               +---------------------------+  |  +------------------+
                                              |
                                              v
                                    +-------------------+
                                    | Continue Game     |
                                    | Processing        |
                                    +-------------------+

Special Cases

Death Triggers

When a commander would die (go to the graveyard from the battlefield), it can create complications with death triggers:

#![allow(unused)]
fn main() {
pub fn handle_commander_death_triggers(
    command_zone: Res<CommandZoneManager>,
    mut death_events: EventReader<DeathEvent>,
    mut death_triggers: EventWriter<TriggerEvent>,
) {
    for event in death_events.read() {
        let entity = event.entity;
        
        // Check if this is a commander that was redirected to the command zone
        if command_zone.is_commander(entity) && 
           command_zone.commander_zone_status.get(&entity) == Some(&CommanderZoneLocation::CommandZone) &&
           command_zone.died_this_turn.contains(&entity) {
            
            // Create death trigger events even though it went to command zone
            death_triggers.send(TriggerEvent::Death {
                entity,
                from_battlefield: true,
                redirected_to_command_zone: true,
            });
        }
    }
}
}

Multiple Zone Changes

If a commander would quickly change zones multiple times, each transition is handled separately:

#![allow(unused)]
fn main() {
pub fn handle_rapid_zone_changes(
    mut command_zone: ResMut<CommandZoneManager>,
    mut transition_events: EventReader<ZoneTransitionEvent>,
    mut choice_events: EventWriter<CommanderZoneChoiceEvent>,
) {
    // Track entities that have already had a choice offered this update
    let mut already_processed = HashSet::new();
    
    for event in transition_events.read() {
        // Skip non-commander entities
        if !command_zone.is_commander(event.entity) {
            continue;
        }
        
        // Skip if we already processed this entity this frame
        if already_processed.contains(&event.entity) {
            continue;
        }
        
        // Process as normal...
        
        // Mark as processed
        already_processed.insert(event.entity);
    }
}
}

The zone transition system is one of the most complex parts of the Commander implementation, as it needs to handle the unique replacement effects that define the format while preserving proper triggers and game state.

Zone Management in Commander

Overview

Zone management in Commander follows the standard Magic: The Gathering zone structure but with special considerations for the Command Zone and commander-specific zone transitions. This document covers the implementation of zone management in the Commander format.

Game Zones

Commander uses all standard Magic: The Gathering zones plus the Command Zone:

#![allow(unused)]
fn main() {
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Zone {
    Battlefield,
    Stack,
    Hand,
    Library,
    Graveyard,
    Exile,
    Command,
    Limbo, // Transitional zone
}
}

Zone Manager Implementation

The Zone Manager keeps track of all entities in each zone:

#![allow(unused)]
fn main() {
#[derive(Resource)]
pub struct ZoneManager {
    // Maps zone type to a set of entities in that zone
    pub zones: HashMap<Zone, HashSet<Entity>>,
    
    // Maps entity to its current zone
    pub entity_zones: HashMap<Entity, Zone>,
    
    // Maps player to their personal zones (hand, library, etc.)
    pub player_zones: HashMap<Entity, HashMap<Zone, HashSet<Entity>>>,
}
}

Key Systems

Zone Initialization

#![allow(unused)]
fn main() {
pub fn initialize_zones(
    mut commands: Commands,
    mut zone_manager: ResMut<ZoneManager>,
    players: Query<Entity, With<Player>>,
) {
    // Initialize global zones
    zone_manager.zones.insert(Zone::Battlefield, HashSet::new());
    zone_manager.zones.insert(Zone::Stack, HashSet::new());
    zone_manager.zones.insert(Zone::Exile, HashSet::new());
    zone_manager.zones.insert(Zone::Command, HashSet::new());
    zone_manager.zones.insert(Zone::Limbo, HashSet::new());
    
    // Initialize player-specific zones
    for player in players.iter() {
        let mut player_zones = HashMap::new();
        player_zones.insert(Zone::Hand, HashSet::new());
        player_zones.insert(Zone::Library, HashSet::new());
        player_zones.insert(Zone::Graveyard, HashSet::new());
        
        zone_manager.player_zones.insert(player, player_zones);
    }
}
}

Zone Transition System

#![allow(unused)]
fn main() {
pub fn handle_zone_transitions(
    mut commands: Commands,
    mut zone_manager: ResMut<ZoneManager>,
    mut events: EventReader<ZoneTransitionEvent>,
    mut commander_events: EventWriter<CommanderZoneTransitionEvent>,
    command_zone: Res<CommandZoneManager>,
) {
    for event in events.read() {
        let entity = event.entity;
        let controller = event.controller;
        let source = event.source;
        let destination = event.destination;
        
        // Check if entity is a commander for special handling
        let is_commander = command_zone.is_commander(entity);
        
        // Special case for commander - emit commander-specific event
        if is_commander {
            commander_events.send(CommanderZoneTransitionEvent {
                commander: entity,
                controller,
                source,
                destination,
                reason: event.reason,
            });
            
            // Let the commander system handle it
            continue;
        }
        
        // Normal zone transition handling
        handle_normal_zone_transition(
            &mut zone_manager, 
            entity, 
            controller, 
            source, 
            destination
        );
    }
}

fn handle_normal_zone_transition(
    zone_manager: &mut ZoneManager,
    entity: Entity,
    controller: Entity,
    source: Zone,
    destination: Zone,
) {
    // Remove from source zone
    match source {
        Zone::Battlefield | Zone::Stack | Zone::Exile | Zone::Command | Zone::Limbo => {
            if let Some(zone_entities) = zone_manager.zones.get_mut(&source) {
                zone_entities.remove(&entity);
            }
        },
        Zone::Hand | Zone::Library | Zone::Graveyard => {
            if let Some(player_zones) = zone_manager.player_zones.get_mut(&controller) {
                if let Some(zone_entities) = player_zones.get_mut(&source) {
                    zone_entities.remove(&entity);
                }
            }
        }
    }
    
    // Add to destination zone
    match destination {
        Zone::Battlefield | Zone::Stack | Zone::Exile | Zone::Command | Zone::Limbo => {
            if let Some(zone_entities) = zone_manager.zones.get_mut(&destination) {
                zone_entities.insert(entity);
            }
        },
        Zone::Hand | Zone::Library | Zone::Graveyard => {
            if let Some(player_zones) = zone_manager.player_zones.get_mut(&controller) {
                if let Some(zone_entities) = player_zones.get_mut(&destination) {
                    zone_entities.insert(entity);
                }
            }
        }
    }
    
    // Update entity's current zone
    zone_manager.entity_zones.insert(entity, destination);
}
}

Command Zone Integration

The Zone Manager integrates with the Command Zone manager to handle special Commander-specific zone transitions:

#![allow(unused)]
fn main() {
pub fn sync_command_zone(
    mut zone_manager: ResMut<ZoneManager>,
    command_zone: Res<CommandZoneManager>,
) {
    // Get all entities currently in the command zone
    let mut command_zone_entities = HashSet::new();
    
    for player_commanders in command_zone.command_zones.values() {
        for commander in player_commanders.iter() {
            command_zone_entities.insert(*commander);
        }
    }
    
    // Update the zone manager's command zone
    if let Some(zone_entities) = zone_manager.zones.get_mut(&Zone::Command) {
        *zone_entities = command_zone_entities;
    }
    
    // Update entity_zones for all commanders
    for (commander, location) in command_zone.commander_zone_status.iter() {
        if *location == CommanderZoneLocation::CommandZone {
            zone_manager.entity_zones.insert(*commander, Zone::Command);
        }
    }
}
}

Special Commander Zone Considerations

Casting From Command Zone

#![allow(unused)]
fn main() {
pub fn enable_command_zone_casting(
    mut commands: Commands,
    zone_manager: Res<ZoneManager>,
    command_zone: Res<CommandZoneManager>,
    mut players: Query<(Entity, &mut PlayerActions)>,
) {
    for (player, mut actions) in players.iter_mut() {
        // Get player's commanders in the command zone
        let commanders = command_zone
            .command_zones
            .get(&player)
            .cloned()
            .unwrap_or_default();
        
        // Add castable action for each commander
        for commander in commanders {
            if let Some(tax) = command_zone.cast_count.get(&commander) {
                actions.castable_from_command_zone.insert(
                    commander, 
                    *tax * 2 // 2 mana per previous cast
                );
            }
        }
    }
}
}

Zone Visibility

Zone visibility in Commander follows standard MTG rules with players only seeing their own hands and libraries:

#![allow(unused)]
fn main() {
pub fn update_zone_visibility(
    mut commands: Commands,
    zone_manager: Res<ZoneManager>,
    players: Query<Entity, With<Player>>,
) {
    for player in players.iter() {
        // Add components for visible zones
        let mut visible_entities = HashSet::new();
        
        // Public zones (visible to all)
        for zone in [Zone::Battlefield, Zone::Stack, Zone::Exile, Zone::Command].iter() {
            if let Some(zone_entities) = zone_manager.zones.get(zone) {
                visible_entities.extend(zone_entities.iter());
            }
        }
        
        // All players' graveyards are public
        for (other_player, zones) in zone_manager.player_zones.iter() {
            if let Some(graveyard) = zones.get(&Zone::Graveyard) {
                visible_entities.extend(graveyard.iter());
            }
        }
        
        // Player's own hand and library (top card only if revealed)
        if let Some(player_zones) = zone_manager.player_zones.get(&player) {
            if let Some(hand) = player_zones.get(&Zone::Hand) {
                visible_entities.extend(hand.iter());
            }
            
            // Other private zone logic...
        }
        
        // Update visibility components
        commands.entity(player).insert(ZoneVisibility {
            visible_entities: visible_entities.into_iter().collect(),
        });
    }
}
}

The Zone Management system provides a complete implementation of Magic: The Gathering zones with special handling for Commander-specific mechanics, particularly related to the Command Zone and commander movement between zones.

Zone Tests

This section contains tests related to the various zone mechanics in the Commander format.

Command Zone Tests

The Command Zone Tests verify the proper behavior of the Command Zone and related mechanics, such as:

  • Commander movement to and from the command zone
  • Commander tax calculation
  • Proper tracking of command zone events
  • Integration with state-based actions

These tests ensure that the unique Commander zone mechanics function correctly within the game engine.

Command Zone Tests

Overview

This document outlines test cases for the Command Zone in the Commander format. These tests ensure correct implementation of all Commander-specific rules related to the Command Zone, commander casting, commander tax, and zone transitions.

Test Case: Commander Casting

Test: Initial Commander Cast from Command Zone

#![allow(unused)]
fn main() {
#[test]
fn test_initial_commander_cast() {
    // Test setup
    let mut app = App::new();
    app.add_plugins(MinimalPlugins)
       .add_systems(Update, (check_commander_cast, update_command_zone));
       
    // Create player and their commander
    let player = app.world.spawn((
        Player {},
        Mana::default(),
        ActivePlayer,
    )).id();
    
    let commander = app.world.spawn((
        Card { 
            name: "Test Commander".to_string(),
            cost: ManaCost::from_string("{2}{G}{G}").unwrap(),
        },
        Commander { owner: player, cast_count: 0 },
        Zone::CommandZone,
    )).id();
    
    // Player has enough mana to cast
    let mut player_mana = app.world.get_mut::<Mana>(player).unwrap();
    player_mana.add(ManaType::Green, 2);
    player_mana.add(ManaType::Colorless, 2);
    
    // Attempt to cast commander
    app.world.send_event(CastSpellEvent {
        caster: player,
        card: commander,
    });
    app.update();
    
    // Verify commander moved to stack
    assert_eq!(app.world.get::<Zone>(commander).unwrap(), &Zone::Stack);
    
    // Verify no additional tax was applied (first cast)
    assert_eq!(app.world.get::<Commander>(commander).unwrap().cast_count, 1);
}
}

Test: Recasting Commander with Commander Tax

#![allow(unused)]
fn main() {
#[test]
fn test_commander_tax() {
    // Test setup
    let mut app = App::new();
    app.add_plugins(MinimalPlugins)
       .add_systems(Update, (check_commander_cast, update_command_zone, apply_commander_tax));
       
    // Create player and their commander
    let player = app.world.spawn((
        Player {},
        Mana::default(),
        ActivePlayer,
    )).id();
    
    let commander = app.world.spawn((
        Card { 
            name: "Test Commander".to_string(),
            cost: ManaCost::from_string("{2}{G}{G}").unwrap(),
        },
        Commander { owner: player, cast_count: 1 }, // Already cast once before
        Zone::CommandZone,
    )).id();
    
    // Player has enough mana to cast with tax
    let mut player_mana = app.world.get_mut::<Mana>(player).unwrap();
    player_mana.add(ManaType::Green, 2);
    player_mana.add(ManaType::Colorless, 4);  // 2 original + 2 tax
    
    // Attempt to cast commander
    app.world.send_event(CastSpellEvent {
        caster: player,
        card: commander,
    });
    app.update();
    
    // Verify commander moved to stack
    assert_eq!(app.world.get::<Zone>(commander).unwrap(), &Zone::Stack);
    
    // Verify cast count was incremented
    assert_eq!(app.world.get::<Commander>(commander).unwrap().cast_count, 2);
    
    // Verify mana was properly deducted including tax
    let player_mana = app.world.get::<Mana>(player).unwrap();
    assert_eq!(player_mana.get(ManaType::Green), 0);
    assert_eq!(player_mana.get(ManaType::Colorless), 0);
}
}

Test: Insufficient Mana for Commander Tax

#![allow(unused)]
fn main() {
#[test]
fn test_insufficient_mana_for_commander_tax() {
    // Test setup
    let mut app = App::new();
    app.add_plugins(MinimalPlugins)
       .add_systems(Update, (check_commander_cast, update_command_zone, apply_commander_tax));
       
    // Create player and their commander
    let player = app.world.spawn((
        Player {},
        Mana::default(),
        ActivePlayer,
    )).id();
    
    let commander = app.world.spawn((
        Card { 
            name: "Test Commander".to_string(),
            cost: ManaCost::from_string("{2}{G}{G}").unwrap(),
        },
        Commander { owner: player, cast_count: 2 }, // Already cast twice before
        Zone::CommandZone,
    )).id();
    
    // Player doesn't have enough mana for tax
    let mut player_mana = app.world.get_mut::<Mana>(player).unwrap();
    player_mana.add(ManaType::Green, 2);
    player_mana.add(ManaType::Colorless, 3);  // 2 original + 4 tax needed, only 3 available
    
    // Attempt to cast commander
    app.world.send_event(CastSpellEvent {
        caster: player,
        card: commander,
    });
    app.update();
    
    // Verify commander stayed in command zone
    assert_eq!(app.world.get::<Zone>(commander).unwrap(), &Zone::CommandZone);
    
    // Verify cast count was not incremented
    assert_eq!(app.world.get::<Commander>(commander).unwrap().cast_count, 2);
}
}

Test Case: Zone Transitions

Test: Commander Dies and Goes to Command Zone

#![allow(unused)]
fn main() {
#[test]
fn test_commander_death_to_command_zone() {
    // Test setup
    let mut app = App::new();
    app.add_plugins(MinimalPlugins)
       .add_systems(Update, (handle_zone_transitions, commander_replacement_effects));
       
    // Create player and their commander
    let player = app.world.spawn((
        Player {},
        CommanderZoneChoice { use_command_zone: true },
    )).id();
    
    let commander = app.world.spawn((
        Card { name: "Test Commander".to_string() },
        Commander { owner: player, cast_count: 1 },
        Zone::Battlefield,
    )).id();
    
    // Trigger death of commander
    app.world.send_event(ZoneChangeEvent {
        entity: commander,
        from: Zone::Battlefield,
        to: Zone::Graveyard,
        cause: ZoneChangeCause::Death,
    });
    app.update();
    
    // Verify commander went to command zone instead of graveyard
    assert_eq!(app.world.get::<Zone>(commander).unwrap(), &Zone::CommandZone);
}
}

Test: Commander Goes to Exile and Replaced to Command Zone

#![allow(unused)]
fn main() {
#[test]
fn test_commander_exile_to_command_zone() {
    // Test setup
    let mut app = App::new();
    app.add_plugins(MinimalPlugins)
       .add_systems(Update, (handle_zone_transitions, commander_replacement_effects));
       
    // Create player and their commander
    let player = app.world.spawn((
        Player {},
        CommanderZoneChoice { use_command_zone: true },
    )).id();
    
    let commander = app.world.spawn((
        Card { name: "Test Commander".to_string() },
        Commander { owner: player, cast_count: 1 },
        Zone::Battlefield,
    )).id();
    
    // Trigger exile of commander
    app.world.send_event(ZoneChangeEvent {
        entity: commander,
        from: Zone::Battlefield,
        to: Zone::Exile,
        cause: ZoneChangeCause::Exile,
    });
    app.update();
    
    // Verify commander went to command zone instead of exile
    assert_eq!(app.world.get::<Zone>(commander).unwrap(), &Zone::CommandZone);
}
}

Test: Commander Goes to Hand by Player Choice

#![allow(unused)]
fn main() {
#[test]
fn test_commander_to_hand_by_choice() {
    // Test setup
    let mut app = App::new();
    app.add_plugins(MinimalPlugins)
       .add_systems(Update, (handle_zone_transitions, commander_replacement_effects));
       
    // Create player and their commander
    let player = app.world.spawn((
        Player {},
        CommanderZoneChoice { use_command_zone: false }, // Choose not to use command zone
    )).id();
    
    let commander = app.world.spawn((
        Card { name: "Test Commander".to_string() },
        Commander { owner: player, cast_count: 1 },
        Zone::Battlefield,
    )).id();
    
    // Trigger bounce of commander
    app.world.send_event(ZoneChangeEvent {
        entity: commander,
        from: Zone::Battlefield,
        to: Zone::Hand,
        cause: ZoneChangeCause::ReturnToHand,
    });
    app.update();
    
    // Verify commander went to hand as chosen
    assert_eq!(app.world.get::<Zone>(commander).unwrap(), &Zone::Hand);
}
}

Test Case: Partner Commanders

Test: Two Partner Commanders in Command Zone

#![allow(unused)]
fn main() {
#[test]
fn test_partner_commanders() {
    // Test setup
    let mut app = App::new();
    app.add_plugins(MinimalPlugins)
       .add_systems(Update, (check_command_zone_validity, handle_partner_mechanics));
       
    // Create player
    let player = app.world.spawn((
        Player {},
        CommanderList::default(),
    )).id();
    
    // Create two partner commanders
    let commander1 = app.world.spawn((
        Card { name: "Partner Commander 1".to_string() },
        Commander { owner: player, cast_count: 0 },
        PartnerAbility,
        Zone::CommandZone,
    )).id();
    
    let commander2 = app.world.spawn((
        Card { name: "Partner Commander 2".to_string() },
        Commander { owner: player, cast_count: 0 },
        PartnerAbility,
        Zone::CommandZone,
    )).id();
    
    // Add commanders to player's commander list
    let mut commander_list = app.world.get_mut::<CommanderList>(player).unwrap();
    commander_list.add(commander1);
    commander_list.add(commander2);
    app.update();
    
    // Verify player can have two commanders because they have partner
    let validation_errors = app.world.resource::<ValidationErrors>();
    assert!(validation_errors.is_empty());
    
    // Verify both commanders are in the command zone
    assert_eq!(app.world.get::<Zone>(commander1).unwrap(), &Zone::CommandZone);
    assert_eq!(app.world.get::<Zone>(commander2).unwrap(), &Zone::CommandZone);
}
}

Test Case: Command Zone Visibility

Test: Command Zone Cards Visible to All Players

#![allow(unused)]
fn main() {
#[test]
fn test_command_zone_visibility() {
    // Test setup
    let mut app = App::new();
    app.add_plugins(MinimalPlugins)
       .add_systems(Update, check_card_visibility);
       
    // Create multiple players
    let player1 = app.world.spawn(Player {}).id();
    let player2 = app.world.spawn(Player {}).id();
    let player3 = app.world.spawn(Player {}).id();
    
    // Create a commander in the command zone
    let commander = app.world.spawn((
        Card { name: "Test Commander".to_string() },
        Commander { owner: player1, cast_count: 0 },
        Zone::CommandZone,
        Visibility::default(),
    )).id();
    
    // Update visibility
    app.update();
    
    // Verify all players can see the commander in command zone
    let visibility = app.world.get::<Visibility>(commander).unwrap();
    assert!(visibility.is_visible_to(player1));
    assert!(visibility.is_visible_to(player2));
    assert!(visibility.is_visible_to(player3));
}
}

Test Case: Commander Identity Restrictions

Test: Card Color Identity Validation for Commander Deck

#![allow(unused)]
fn main() {
#[test]
fn test_color_identity_restrictions() {
    // Test setup
    let mut app = App::new();
    app.add_plugins(MinimalPlugins)
       .add_systems(Update, validate_deck_color_identity);
       
    // Create player and commander
    let player = app.world.spawn(Player {}).id();
    
    let commander = app.world.spawn((
        Card { name: "Mono-Green Commander".to_string() },
        Commander { owner: player, cast_count: 0 },
        ColorIdentity { colors: vec![Color::Green] },
    )).id();
    
    // Create legal and illegal cards for the deck
    let legal_card = app.world.spawn((
        Card { name: "Green Card".to_string() },
        ColorIdentity { colors: vec![Color::Green] },
        InDeck { owner: player },
    )).id();
    
    let illegal_card = app.world.spawn((
        Card { name: "Blue Card".to_string() },
        ColorIdentity { colors: vec![Color::Blue] },
        InDeck { owner: player },
    )).id();
    
    // Create deck with the commander
    app.world.spawn((
        Deck { owner: player },
        CommanderDeck { commander: commander },
        Cards { entities: vec![legal_card, illegal_card] },
    ));
    
    // Validate deck
    app.update();
    
    // Verify validation errors for illegal card
    let validation_errors = app.world.resource::<ValidationErrors>();
    assert!(!validation_errors.is_empty());
    assert!(validation_errors.contains_error_about(illegal_card, "color identity"));
}
}

These test cases provide comprehensive coverage of the Command Zone mechanics and ensure that all the format-specific rules are correctly implemented and enforced.

Turns and Phases

This section covers the implementation of turn structure and phases in the Commander format.

Contents

The turns and phases section defines how the turn structure of Commander games is implemented, with special consideration for multiplayer dynamics:

  • Standard Magic turn structure (untap, upkeep, draw, main phases, combat, etc.)
  • Multiplayer turn order determination and rotation
  • Special handling for simultaneous player actions
  • Turn-based effects in multiplayer contexts
  • Turn modification effects (extra turns, skipped phases, etc.)

While the basic turn structure in Commander follows standard Magic rules, the multiplayer nature of the format introduces complexity in turn management, particularly around priority passing and simultaneous effects.

Turn Structure

Overview

The Turn Structure module manages the flow of a Commander game, handling the sequence of phases and steps within each player's turn. It coordinates player transitions, priority passing, and phase-specific actions while accounting for the multiplayer nature of Commander.

Core Turn Sequence

A turn in Commander follows the standard Magic: The Gathering sequence:

  1. Beginning Phase

    • Untap Step
    • Upkeep Step
    • Draw Step
  2. Precombat Main Phase

  3. Combat Phase

    • Beginning of Combat Step
    • Declare Attackers Step
    • Declare Blockers Step
    • Combat Damage Step
    • End of Combat Step
  4. Postcombat Main Phase

  5. Ending Phase

    • End Step
    • Cleanup Step

Multiplayer Considerations

In Commander, turn order proceeds clockwise from the starting player. The format introduces special considerations:

  • Turn Order Determination: Typically random at the start of the game
  • Player Elimination: When a player loses, turns continue with the remaining players
  • Extra Turns: Cards that grant extra turns work the same as in standard Magic
  • "Skip your next turn" effects: These follow standard Magic rules but can have significant political impact

Data Structures

The turn structure is managed through the following robust data structures:

#![allow(unused)]
fn main() {
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Phase {
    Beginning(BeginningStep),
    Precombat(PrecombatStep),
    Combat(CombatStep),
    Postcombat(PostcombatStep),
    Ending(EndingStep),
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum BeginningStep {
    Untap,
    Upkeep,
    Draw,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum PrecombatStep {
    Main,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum CombatStep {
    Beginning,
    DeclareAttackers,
    DeclareBlockers,
    FirstStrike,
    CombatDamage,
    End,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum PostcombatStep {
    Main,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum EndingStep {
    End,
    Cleanup,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ExtraTurnSource {
    Card(Entity),
    Ability(Entity, Entity), // (Source, Ability)
    Rule,
}

#[derive(Resource)]
pub struct TurnManager {
    // Current turn information
    pub current_phase: Phase,
    pub active_player_index: usize,
    pub turn_number: u32,
    
    // Priority system
    pub priority_player_index: usize,
    pub all_players_passed: bool,
    pub stack_is_empty: bool,
    
    // Multiplayer tracking
    pub player_order: Vec<Entity>,
    pub extra_turns: VecDeque<(Entity, ExtraTurnSource)>,
    pub skipped_turns: HashSet<Entity>,
    
    // Time tracking
    pub time_limits: HashMap<Entity, Duration>,
    pub turn_started_at: Option<Instant>,
    
    // Phase history for reverting game states and debugging
    pub phase_history: VecDeque<(Phase, Entity)>,
    
    // Special phase tracking
    pub additional_phases: VecDeque<(Phase, Entity)>,
    pub modified_phases: HashMap<Phase, PhaseModification>,
}

#[derive(Debug, Clone)]
pub struct PhaseModification {
    pub source: Entity,
    pub skip_priority: bool,
    pub skip_actions: bool,
    pub additional_actions: Vec<GameAction>,
}
}

Detailed Implementation

Phase Progression System

#![allow(unused)]
fn main() {
impl TurnManager {
    // Advances to the next phase in the turn sequence
    pub fn advance_phase(&mut self) -> Result<Phase, TurnError> {
        let previous_phase = self.current_phase;
        self.current_phase = match self.current_phase {
            Phase::Beginning(BeginningStep::Untap) => Phase::Beginning(BeginningStep::Upkeep),
            Phase::Beginning(BeginningStep::Upkeep) => Phase::Beginning(BeginningStep::Draw),
            Phase::Beginning(BeginningStep::Draw) => Phase::Precombat(PrecombatStep::Main),
            Phase::Precombat(PrecombatStep::Main) => Phase::Combat(CombatStep::Beginning),
            Phase::Combat(CombatStep::Beginning) => Phase::Combat(CombatStep::DeclareAttackers),
            Phase::Combat(CombatStep::DeclareAttackers) => Phase::Combat(CombatStep::DeclareBlockers),
            // Check if first strike damage should happen
            Phase::Combat(CombatStep::DeclareBlockers) => {
                if self.has_first_strike_creatures() {
                    Phase::Combat(CombatStep::FirstStrike)
                } else {
                    Phase::Combat(CombatStep::CombatDamage)
                }
            },
            Phase::Combat(CombatStep::FirstStrike) => Phase::Combat(CombatStep::CombatDamage),
            Phase::Combat(CombatStep::CombatDamage) => Phase::Combat(CombatStep::End),
            Phase::Combat(CombatStep::End) => Phase::Postcombat(PostcombatStep::Main),
            Phase::Postcombat(PostcombatStep::Main) => Phase::Ending(EndingStep::End),
            Phase::Ending(EndingStep::End) => Phase::Ending(EndingStep::Cleanup),
            Phase::Ending(EndingStep::Cleanup) => {
                // Check for additional or extra turns
                self.process_turn_end()?;
                // The next turn's first phase
                Phase::Beginning(BeginningStep::Untap)
            }
        };
        
        // Record phase change in history
        self.phase_history.push_back((previous_phase, self.get_active_player()));
        
        // Check if this phase should be modified or skipped
        if let Some(modification) = self.modified_phases.get(&self.current_phase) {
            if modification.skip_actions {
                // Skip this phase and move to the next one
                return self.advance_phase();
            }
        }
        
        // Handle priority assignment for the new phase
        self.reset_priority_for_new_phase();
        
        Ok(self.current_phase)
    }
    
    // Process the end of a turn
    fn process_turn_end(&mut self) -> Result<(), TurnError> {
        // Check if there are additional phases for this turn
        if let Some((phase, _)) = self.additional_phases.pop_front() {
            self.current_phase = phase;
            return Ok(());
        }
        
        // Move to the next player
        let next_player = self.determine_next_player()?;
        self.active_player_index = self.player_order.iter()
            .position(|&p| p == next_player)
            .ok_or(TurnError::InvalidPlayerIndex)?;
        self.turn_number += 1;
        
        // Reset turn timer
        self.turn_started_at = Some(Instant::now());
        
        Ok(())
    }
    
    // Determine who takes the next turn
    fn determine_next_player(&mut self) -> Result<Entity, TurnError> {
        // Check for extra turns first
        if let Some((player, source)) = self.extra_turns.pop_front() {
            // Log extra turn for game record
            info!("Player {:?} is taking an extra turn from source {:?}", player, source);
            return Ok(player);
        }
        
        // Get the next player in turn order
        let next_index = (self.active_player_index + 1) % self.player_order.len();
        let next_player = self.player_order[next_index];
        
        // Check if the next player should skip their turn
        if self.skipped_turns.remove(&next_player) {
            // Record the skipped turn
            info!("Player {:?} is skipping their turn", next_player);
            
            // Move to the player after that
            self.active_player_index = next_index;
            return self.determine_next_player();
        }
        
        Ok(next_player)
    }
}
}

Priority System

#![allow(unused)]
fn main() {
impl TurnManager {
    // Reset priority for a new phase
    fn reset_priority_for_new_phase(&mut self) {
        self.all_players_passed = false;
        
        // In most phases, active player gets priority first
        self.priority_player_index = self.active_player_index;
        
        // For Beginning::Untap and Ending::Cleanup, no player gets priority by default
        match self.current_phase {
            Phase::Beginning(BeginningStep::Untap) | Phase::Ending(EndingStep::Cleanup) => {
                self.all_players_passed = true;
            },
            _ => {}
        }
    }
    
    // Pass priority to the next player
    pub fn pass_priority(&mut self) -> Result<(), TurnError> {
        // Calculate next player with priority
        let next_index = (self.priority_player_index + 1) % self.player_order.len();
        
        // If we're back to the active player, everyone has passed
        if next_index == self.active_player_index {
            self.all_players_passed = true;
            
            // If the stack is empty and everyone has passed, move to the next phase
            if self.stack_is_empty {
                self.advance_phase()?;
            } else {
                // Resolve the top item on the stack
                // This would trigger a different system
                self.stack_is_empty = false; // This would be set by the stack system
            }
        } else {
            // Pass to the next player
            self.priority_player_index = next_index;
        }
        
        Ok(())
    }
}
}

Edge Cases and Their Handling

Player Elimination During a Turn

#![allow(unused)]
fn main() {
impl TurnManager {
    // Handle a player being eliminated
    pub fn handle_player_elimination(&mut self, eliminated_player: Entity) -> Result<(), TurnError> {
        // Remove player from the turn order
        let index = self.player_order.iter()
            .position(|&p| p == eliminated_player)
            .ok_or(TurnError::PlayerNotFound)?;
        self.player_order.remove(index);
        
        // Adjust active player index if necessary
        if index <= self.active_player_index && self.active_player_index > 0 {
            self.active_player_index -= 1;
        }
        
        // Adjust priority player index if necessary
        if index <= self.priority_player_index && self.priority_player_index > 0 {
            self.priority_player_index -= 1;
        }
        
        // Remove any extra or skipped turns for this player
        self.extra_turns.retain(|(player, _)| *player != eliminated_player);
        self.skipped_turns.remove(&eliminated_player);
        
        // If the active player was eliminated
        if index == self.active_player_index {
            // Move to the next player's turn
            let next_player = self.determine_next_player()?;
            self.active_player_index = self.player_order.iter()
                .position(|&p| p == next_player)
                .ok_or(TurnError::InvalidPlayerIndex)?;
            
            // Reset to the beginning of the turn
            self.current_phase = Phase::Beginning(BeginningStep::Untap);
            self.reset_priority_for_new_phase();
        }
        
        Ok(())
    }
}
}

Handling Multiple Extra Turns

When multiple extra turns are queued, they're processed in the order they were created:

#![allow(unused)]
fn main() {
impl TurnManager {
    // Add an extra turn to the queue
    pub fn add_extra_turn(&mut self, player: Entity, source: ExtraTurnSource) {
        self.extra_turns.push_back((player, source));
    }
}
}

Time Limits and Slow Play

#![allow(unused)]
fn main() {
impl TurnManager {
    // Check if a player has exceeded their time limit
    pub fn check_time_limit(&self, player: Entity) -> Result<bool, TurnError> {
        if let Some(limit) = self.time_limits.get(&player) {
            if let Some(start_time) = self.turn_started_at {
                return Ok(start_time.elapsed() > *limit);
            }
        }
        Ok(false)
    }
}
}

Verification and Testing Strategy

Unit Tests

#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
    use super::*;
    
    // Test normal phase progression
    #[test]
    fn test_normal_phase_progression() {
        let mut turn_manager = setup_test_turn_manager();
        
        // Start at Beginning::Untap
        assert_eq!(turn_manager.current_phase, Phase::Beginning(BeginningStep::Untap));
        
        // Advance through all phases
        for _ in 0..12 {  // Total number of phases/steps in a turn
            turn_manager.advance_phase().unwrap();
        }
        
        // Should be back at Beginning::Untap for the next player
        assert_eq!(turn_manager.current_phase, Phase::Beginning(BeginningStep::Untap));
        assert_eq!(turn_manager.active_player_index, 1); // Next player
    }
    
    // Test extra turn handling
    #[test]
    fn test_extra_turn() {
        let mut turn_manager = setup_test_turn_manager();
        let player1 = turn_manager.player_order[0];
        
        // Add an extra turn for the current player
        turn_manager.add_extra_turn(player1, ExtraTurnSource::Rule);
        
        // Advance through a full turn
        for _ in 0..12 {
            turn_manager.advance_phase().unwrap();
        }
        
        // Should still be the same player's turn
        assert_eq!(turn_manager.active_player_index, 0);
    }
    
    // Test skipped turn
    #[test]
    fn test_skipped_turn() {
        let mut turn_manager = setup_test_turn_manager();
        let player2 = turn_manager.player_order[1];
        
        // Mark player 2's turn to be skipped
        turn_manager.skipped_turns.insert(player2);
        
        // Advance through a full turn
        for _ in 0..12 {
            turn_manager.advance_phase().unwrap();
        }
        
        // Should skip to player 3
        assert_eq!(turn_manager.active_player_index, 2);
    }
    
    // Test player elimination
    #[test]
    fn test_player_elimination() {
        let mut turn_manager = setup_test_turn_manager();
        let player2 = turn_manager.player_order[1];
        
        // Eliminate player 2
        turn_manager.handle_player_elimination(player2).unwrap();
        
        // Check player order is updated
        assert_eq!(turn_manager.player_order.len(), 3);
        assert!(!turn_manager.player_order.contains(&player2));
        
        // Complete current turn
        for _ in 0..12 {
            turn_manager.advance_phase().unwrap();
        }
        
        // Should skip from player 1 to player 3 (now at index 1)
        assert_eq!(turn_manager.active_player_index, 1);
    }
    
    // Test priority passing
    #[test]
    fn test_priority_passing() {
        let mut turn_manager = setup_test_turn_manager();
        
        // Move to a phase where priority is assigned
        turn_manager.advance_phase().unwrap(); // To Beginning::Upkeep
        
        // Active player should have priority
        assert_eq!(turn_manager.priority_player_index, 0);
        
        // Pass priority to each player
        for i in 1..4 {
            turn_manager.pass_priority().unwrap();
            assert_eq!(turn_manager.priority_player_index, i);
        }
        
        // After all players pass, should move to next phase
        turn_manager.pass_priority().unwrap();
        assert_eq!(turn_manager.current_phase, Phase::Beginning(BeginningStep::Draw));
    }
    
    // Setup helper
    fn setup_test_turn_manager() -> TurnManager {
        let player1 = Entity::from_raw(1);
        let player2 = Entity::from_raw(2);
        let player3 = Entity::from_raw(3);
        let player4 = Entity::from_raw(4);
        
        TurnManager {
            current_phase: Phase::Beginning(BeginningStep::Untap),
            active_player_index: 0,
            turn_number: 1,
            priority_player_index: 0,
            all_players_passed: true,
            stack_is_empty: true,
            player_order: vec![player1, player2, player3, player4],
            extra_turns: VecDeque::new(),
            skipped_turns: HashSet::new(),
            time_limits: HashMap::new(),
            turn_started_at: Some(Instant::now()),
            phase_history: VecDeque::new(),
            additional_phases: VecDeque::new(),
            modified_phases: HashMap::new(),
        }
    }
}
}

Integration Tests

#![allow(unused)]
fn main() {
#[cfg(test)]
mod integration_tests {
    use super::*;
    use crate::game_engine::stack::Stack;
    use crate::game_engine::state::GameState;
    
    // Test turns and stack interaction
    #[test]
    fn test_turns_with_stack() {
        let mut app = App::new();
        
        // Set up game resources
        app.insert_resource(setup_test_turn_manager());
        app.insert_resource(Stack::default());
        app.insert_resource(GameState::default());
        
        // Add relevant systems
        app.add_systems(Update, (
            phase_transition_system,
            stack_resolution_system,
            priority_system,
        ));
        
        // Simulate a turn with stack interactions
        app.update();
        
        // Add a spell to the stack
        let mut stack = app.world.resource_mut::<Stack>();
        stack.push(create_test_spell());
        
        // Verify stack is not empty
        let turn_manager = app.world.resource::<TurnManager>();
        assert!(!turn_manager.stack_is_empty);
        
        // Simulate priority passing and stack resolution
        for _ in 0..5 {
            app.update();
        }
        
        // Stack should be empty and phase should have advanced
        let turn_manager = app.world.resource::<TurnManager>();
        assert!(turn_manager.stack_is_empty);
        assert_eq!(turn_manager.current_phase, Phase::Beginning(BeginningStep::Draw));
    }
    
    // Test full turn cycle with multiple players
    #[test]
    fn test_full_turn_cycle() {
        let mut app = App::new();
        
        // Set up game resources
        app.insert_resource(setup_test_turn_manager());
        
        // Add systems
        app.add_systems(Update, phase_transition_system);
        
        // Track starting player
        let start_player = app.world.resource::<TurnManager>().active_player_index;
        
        // Simulate a full turn cycle (all players take one turn)
        for _ in 0..48 {  // 4 players × 12 phases
            app.update();
        }
        
        // Should be back to starting player
        let turn_manager = app.world.resource::<TurnManager>();
        assert_eq!(turn_manager.active_player_index, start_player);
        assert_eq!(turn_manager.turn_number, 5); // Starting turn + 4 players
    }
}
}

End-to-End Testing

#![allow(unused)]
fn main() {
#[cfg(test)]
mod e2e_tests {
    use super::*;
    use crate::test_utils::setup_full_game;
    
    // Test a full game scenario
    #[test]
    fn test_game_until_winner() {
        let mut app = setup_full_game(4); // 4 players
        
        // Run the game until only one player remains
        let max_turns = 100; // Prevent infinite loop
        for _ in 0..max_turns {
            app.update();
            
            // Check if the game has ended
            let turn_manager = app.world.resource::<TurnManager>();
            if turn_manager.player_order.len() == 1 {
                break;
            }
            
            // Simulate random player actions based on the current phase
            simulate_player_actions(&mut app);
        }
        
        // Verify we have a winner
        let turn_manager = app.world.resource::<TurnManager>();
        assert_eq!(turn_manager.player_order.len(), 1, "Game should end with one player remaining");
    }
    
    // Test complex turn interaction with time limits
    #[test]
    fn test_time_limits() {
        let mut app = setup_full_game(4);
        
        // Set time limits for all players
        {
            let mut turn_manager = app.world.resource_mut::<TurnManager>();
            for &player in &turn_manager.player_order {
                turn_manager.time_limits.insert(player, Duration::from_secs(30));
            }
        }
        
        // Fast-forward time for current player
        let current_player = {
            let turn_manager = app.world.resource::<TurnManager>();
            turn_manager.player_order[turn_manager.active_player_index]
        };
        
        // Mock time advancing past the limit for testing
        {
            let mut turn_manager = app.world.resource_mut::<TurnManager>();
            turn_manager.turn_started_at = Some(Instant::now() - Duration::from_secs(31));
        }
        
        // Add system to check time limits
        app.add_systems(Update, check_time_limits_system);
        
        // Run one update
        app.update();
        
        // Verify player's turn was ended due to time limit
        let turn_manager = app.world.resource::<TurnManager>();
        assert_ne!(turn_manager.active_player_index, 
                  turn_manager.player_order.iter().position(|&p| p == current_player).unwrap());
    }
}
}

Performance Considerations

  • The TurnManager uses efficient data structures to minimize overhead:

    • HashMap for O(1) lookup of player time limits
    • VecDeque for O(1) queue operations for extra turns and phase history
    • HashSet for O(1) lookup of skipped turns
  • Phase transitions are optimized to only trigger necessary game events

  • Time complexity analysis:

    • Advancing phase: O(1)
    • Determining next player: O(1) in most cases, O(n) worst case (all players skip)
    • Priority passing: O(1)
    • Player elimination: O(n) where n is the number of players

Extensibility

The design allows for easy extension with new phases or rules:

  • Additional phases can be added to the Phase enum
  • Custom phase progression can be implemented by overriding the advance_phase method
  • Game variants can be supported by modifying the TurnManager behavior

By following this implementation approach, the turn structure will robustly handle all standard Magic: The Gathering rules as well as the specific requirements of the Commander format.

Priority System

Overview

The priority system determines which player may take game actions at any given time during a Commander game. In a multiplayer format like Commander, properly managing priority is essential for maintaining game flow and ensuring all players have appropriate opportunities to respond.

Priority Rules

  1. The active player receives priority at the beginning of each step or phase, except the untap and cleanup steps
  2. When a player has priority, they may:
    • Cast a spell
    • Activate an ability
    • Take a special action
    • Pass priority
  3. After a player casts a spell or activates an ability, they receive priority again
  4. Priority passes in turn order (clockwise) from the active player
  5. When all players pass priority in succession:
    • If the stack is not empty, the top item on the stack resolves, then the active player gets priority
    • If the stack is empty, the current step or phase ends and the game moves to the next step or phase

Implementation

The priority system is tracked within the TurnManager resource:

#![allow(unused)]
fn main() {
#[derive(Resource)]
pub struct TurnManager {
    // Priority system fields
    pub priority_player_index: usize,
    pub all_players_passed: bool,
    pub stack_is_empty: bool,
    
    // Step tracking for UI and timers
    pub step_started_at: std::time::Instant,
    pub auto_pass_enabled: bool,
    pub auto_pass_delay: std::time::Duration,
    
    // Other fields
    // ...
}
}

Special Priority Cases

Simultaneous Actions

When multiple players would take actions simultaneously:

  1. First, the active player performs all their actions in any order they choose
  2. Then, each non-active player in turn order performs their actions

Auto-Pass System

For better digital gameplay experience, the system implements an auto-pass feature:

#![allow(unused)]
fn main() {
pub fn check_auto_pass_system(
    time: Res<Time>,
    mut turn_manager: ResMut<TurnManager>,
    player_states: Query<&PlayerState>,
) {
    if turn_manager.auto_pass_enabled && 
       time.elapsed_seconds() - turn_manager.step_started_at > turn_manager.auto_pass_delay {
        // Auto-pass for the current priority player if they have no available actions
        // ...
    }
}
}

Stop Button

Players can place "stops" on specific phases and steps to ensure they receive priority, even when auto-pass is enabled:

#![allow(unused)]
fn main() {
#[derive(Component)]
pub struct PlayerStops {
    pub phases: HashMap<Phase, bool>,
    // Additional stop settings
}
}

UI Representation

The priority system is visually represented to players through:

  1. A highlighted border around the active player's avatar
  2. A timer indicator showing how long the current player has had priority
  3. Special effects when priority passes
  4. Visual cues for auto-pass situations

Multiplayer Considerations

In Commander, the priority system manages additional complexity:

  • Tracking priority across 3-6 players
  • Handling "table talk" periods during complex game states
  • Supporting take-backs by mutual agreement (optional house rule)
  • Managing disconnected players in digital play

Multiplayer Turns in Commander

This document explains how multiplayer turns are implemented in the Rummage game engine for the Commander format.

Multiplayer Turn Structure

Commander is designed as a multiplayer format, typically played with 3-6 players. The multiplayer turn structure follows these rules:

  1. Players take turns in clockwise order, starting from a randomly determined first player
  2. Turn order remains fixed throughout the game unless modified by card effects
  3. All standard turn phases occur during each player's turn
  4. Players can act during other players' turns when they have priority
  5. Some effects specifically reference "each player" or "each opponent" which have multiplayer implications

Turn Order Implementation

#![allow(unused)]
fn main() {
/// Resource that tracks turn order
#[derive(Resource, Debug, Clone)]
pub struct TurnOrder {
    /// List of players in turn order
    pub players: Vec<Entity>,
    /// Current player index in the players list
    pub current_player_index: usize,
    /// Direction of turn progression (1 for clockwise, -1 for counter-clockwise)
    pub direction: i32,
}

impl TurnOrder {
    /// Creates a new turn order with the specified players
    pub fn new(players: Vec<Entity>) -> Self {
        Self {
            players,
            current_player_index: 0,
            direction: 1,
        }
    }
    
    /// Gets the current player
    pub fn current_player(&self) -> Entity {
        self.players[self.current_player_index]
    }
    
    /// Advances to the next player's turn
    pub fn advance(&mut self) {
        let len = self.players.len() as i32;
        self.current_player_index = (
            (self.current_player_index as i32 + self.direction).rem_euclid(len)
        ) as usize;
    }
    
    /// Reverses the turn order direction
    pub fn reverse(&mut self) {
        self.direction = -self.direction;
    }
    
    /// Shuffles the player order
    pub fn shuffle(&mut self, rng: &mut impl Rng) {
        self.players.shuffle(rng);
        // Maintain current player at index 0 after shuffle
        let current = self.current_player();
        let current_pos = self.players.iter().position(|&p| p == current).unwrap();
        self.players.swap(0, current_pos);
        self.current_player_index = 0;
    }
    
    /// Skips the next player's turn
    pub fn skip_next(&mut self) {
        self.advance();
    }
    
    /// Returns an iterator over all players starting from the current player
    pub fn iter_from_current(&self) -> impl Iterator<Item = Entity> + '_ {
        let len = self.players.len();
        (0..len).map(move |offset| {
            let index = (self.current_player_index + offset) % len;
            self.players[index]
        })
    }
    
    /// Returns an iterator over all opponents of the current player
    pub fn iter_opponents(&self) -> impl Iterator<Item = Entity> + '_ {
        let current = self.current_player();
        self.players.iter().copied().filter(move |&p| p != current)
    }
}
}

Turn Advancement System

#![allow(unused)]
fn main() {
/// System that advances to the next player's turn
pub fn advance_turn(
    mut turn_order: ResMut<TurnOrder>,
    mut turn_phase: ResMut<TurnPhase>,
    mut turn_events: EventWriter<TurnEvent>,
    mut game_state: ResMut<GameState>,
    time: Res<Time>,
) {
    if game_state.current_state != GameStateType::EndOfTurn {
        return;
    }
    
    // Get current player before advancing
    let previous_player = turn_order.current_player();
    
    // Advance to next player
    turn_order.advance();
    let next_player = turn_order.current_player();
    
    // Reset turn phase to beginning of turn
    *turn_phase = TurnPhase::Beginning(BeginningPhase::Untap);
    
    // Update game state
    game_state.current_state = GameStateType::ActiveTurn;
    game_state.active_player = next_player;
    
    // Send events
    turn_events.send(TurnEvent::TurnEnded { player: previous_player });
    turn_events.send(TurnEvent::TurnBegan { player: next_player });
}
}

Multiplayer-Specific Mechanics

Turn-Based Effects

Some effects need to track when players take turns:

#![allow(unused)]
fn main() {
/// Component for effects that trigger at the beginning of a player's turn
#[derive(Component, Debug, Clone)]
pub struct BeginningOfTurnTrigger {
    /// Whether this triggers on the controller's turn only
    pub controller_only: bool,
    /// Whether this triggers on opponents' turns only
    pub opponents_only: bool,
    /// Function to execute when triggered
    pub effect: fn(Commands, Entity) -> (),
}

/// System that handles beginning of turn triggers
pub fn handle_beginning_of_turn_triggers(
    mut commands: Commands,
    turn_order: Res<TurnOrder>,
    turn_phase: Res<TurnPhase>,
    triggers: Query<(Entity, &BeginningOfTurnTrigger, &Owner)>,
) {
    // Only execute during the beginning of turn phase
    if !matches!(turn_phase.as_ref(), TurnPhase::Beginning(BeginningPhase::Upkeep)) {
        return;
    }
    
    let current_player = turn_order.current_player();
    
    for (entity, trigger, owner) in triggers.iter() {
        let is_owner_turn = owner.0 == current_player;
        
        // Check if trigger condition is met
        if (trigger.controller_only && is_owner_turn) ||
           (trigger.opponents_only && !is_owner_turn) ||
           (!trigger.controller_only && !trigger.opponents_only) {
            (trigger.effect)(commands, entity);
        }
    }
}
}

Simultaneous Effects

In multiplayer games, effects that affect all players need special handling:

#![allow(unused)]
fn main() {
/// Handles effects that apply to all players simultaneously
pub fn handle_global_effects(
    mut commands: Commands,
    players: Query<Entity, With<Player>>,
    global_effects: Query<(Entity, &GlobalEffect)>,
) {
    for (effect_entity, global_effect) in global_effects.iter() {
        match global_effect.target_type {
            GlobalTargetType::AllPlayers => {
                for player in players.iter() {
                    (global_effect.apply_effect)(commands, effect_entity, player);
                }
            },
            GlobalTargetType::AllOpponents => {
                let turn_order = commands.world().resource::<TurnOrder>();
                let current_player = turn_order.current_player();
                
                for player in players.iter() {
                    if player != current_player {
                        (global_effect.apply_effect)(commands, effect_entity, player);
                    }
                }
            },
            // Other global target types...
        }
    }
}
}

Special Turn Interactions

Extra Turns

Commander allows for extra turn effects:

#![allow(unused)]
fn main() {
#[derive(Resource, Debug)]
pub struct ExtraTurns {
    /// Queue of players taking extra turns
    pub queue: VecDeque<Entity>,
}

/// System that handles extra turn insertion
pub fn handle_extra_turns(
    mut turn_order: ResMut<TurnOrder>,
    mut extra_turns: ResMut<ExtraTurns>,
    mut turn_events: EventWriter<TurnEvent>,
) {
    if let Some(player) = extra_turns.queue.pop_front() {
        // Current player takes an extra turn
        let current = turn_order.current_player();
        if player == current {
            turn_events.send(TurnEvent::ExtraTurn { player });
        } else {
            // Another player takes the next turn
            // Temporarily modify turn order
            let current_index = turn_order.current_player_index;
            let player_index = turn_order.players.iter().position(|&p| p == player).unwrap();
            turn_order.current_player_index = player_index;
            turn_events.send(TurnEvent::ExtraTurn { player });
            
            // Store original order to restore after the extra turn
            commands.insert_resource(PendingTurnRestoration {
                restore_index: current_index,
            });
        }
    }
}
}

Turn Modifications

Some cards can modify turn order or skip turns:

#![allow(unused)]
fn main() {
/// Event for turn order modifications
#[derive(Event, Debug, Clone)]
pub enum TurnOrderEvent {
    /// Reverses turn order direction
    Reverse,
    /// Skips the next player's turn
    SkipNext,
    /// Takes an extra turn after the current one
    ExtraTurn { player: Entity },
    /// Exchanges turns between players
    ExchangeTurn { player_a: Entity, player_b: Entity },
}

/// System that handles turn order modifications
pub fn handle_turn_modifications(
    mut turn_order: ResMut<TurnOrder>,
    mut extra_turns: ResMut<ExtraTurns>,
    mut turn_events: EventReader<TurnOrderEvent>,
    mut game_events: EventWriter<GameEvent>,
) {
    for event in turn_events.read() {
        match event {
            TurnOrderEvent::Reverse => {
                turn_order.reverse();
                game_events.send(GameEvent::TurnOrderReversed);
            },
            TurnOrderEvent::SkipNext => {
                turn_order.skip_next();
                game_events.send(GameEvent::TurnSkipped);
            },
            TurnOrderEvent::ExtraTurn { player } => {
                extra_turns.queue.push_back(*player);
                game_events.send(GameEvent::ExtraTurnAdded { player: *player });
            },
            TurnOrderEvent::ExchangeTurn { player_a, player_b } => {
                let pos_a = turn_order.players.iter().position(|&p| p == *player_a).unwrap();
                let pos_b = turn_order.players.iter().position(|&p| p == *player_b).unwrap();
                turn_order.players.swap(pos_a, pos_b);
                game_events.send(GameEvent::TurnOrderModified);
            },
        }
    }
}
}

UI Representation

Multiplayer turns are visually represented in the UI:

  1. Turn Order Display: Shows all players in order with the current player highlighted
  2. Active Player Indicator: Clearly highlights whose turn it is
  3. Turn Direction Indicator: Shows the current direction of play (clockwise/counter-clockwise)
  4. Extra Turn Queue: Displays any pending extra turns
  5. Phase Tracker: Shows the current phase of the active player's turn

Testing

Example Test

#![allow(unused)]
fn main() {
#[test]
fn test_multiplayer_turn_order() {
    // Create test app with required systems
    let mut app = App::new();
    app.add_systems(Update, advance_turn)
        .add_event::<TurnEvent>()
        .init_resource::<GameState>();
    
    // Create four players for a typical 4-player game
    let player1 = app.world.spawn_empty().id();
    let player2 = app.world.spawn_empty().id();
    let player3 = app.world.spawn_empty().id();
    let player4 = app.world.spawn_empty().id();
    
    let players = vec![player1, player2, player3, player4];
    
    // Initialize turn order
    app.insert_resource(TurnOrder::new(players.clone()));
    app.insert_resource(TurnPhase::Ending(EndingPhase::End));
    
    // Set game state to end of turn to trigger advancement
    let mut game_state = GameState::default();
    game_state.current_state = GameStateType::EndOfTurn;
    app.insert_resource(game_state);
    
    // First turn should be player1
    let turn_order = app.world.resource::<TurnOrder>();
    assert_eq!(turn_order.current_player(), player1);
    
    // Advance turn
    app.update();
    
    // Should now be player2's turn
    let turn_order = app.world.resource::<TurnOrder>();
    assert_eq!(turn_order.current_player(), player2);
    
    // Test turn events
    let events = app.world.resource::<Events<TurnEvent>>();
    let mut reader = events.get_reader();
    
    let mut saw_end = false;
    let mut saw_begin = false;
    
    for event in reader.read(&events) {
        match event {
            TurnEvent::TurnEnded { player } => {
                assert_eq!(*player, player1);
                saw_end = true;
            },
            TurnEvent::TurnBegan { player } => {
                assert_eq!(*player, player2);
                saw_begin = true;
            },
            _ => {}
        }
    }
    
    assert!(saw_end, "Should have seen turn ended event");
    assert!(saw_begin, "Should have seen turn began event");
}

#[test]
fn test_reversed_turn_order() {
    // Similar test setup
    let mut app = App::new();
    // ...
    
    // Insert reversed turn order
    let mut turn_order = TurnOrder::new(players.clone());
    turn_order.reverse(); // Direction becomes -1
    app.insert_resource(turn_order);
    
    // Advance from player1
    app.update();
    
    // Should now be player4's turn (counter-clockwise)
    let turn_order = app.world.resource::<TurnOrder>();
    assert_eq!(turn_order.current_player(), player4);
}
}

Summary

Multiplayer turns in Commander are implemented with a flexible system that:

  1. Maintains proper turn order in multiplayer games
  2. Supports direction changes (clockwise/counter-clockwise)
  3. Handles extra turns and turn skipping
  4. Processes effects that affect multiple players
  5. Provides clear UI indicators for turn progression
  6. Is thoroughly tested for all turn modification scenarios

Extra Turns and Turn Modifications

Overview

Commander implements various turn modification effects from Magic: The Gathering, including extra turns, skipped turns, and modified turn structures. These effects are particularly impactful in a multiplayer environment.

Extra Turns

Extra turns are implemented through a queue system in the TurnManager:

#![allow(unused)]
fn main() {
#[derive(Resource)]
pub struct TurnManager {
    // Other fields...
    pub extra_turns: VecDeque<(Entity, ExtraTurnSource)>,
    pub skipped_turns: HashSet<Entity>,
}

#[derive(Debug, Clone)]
pub struct ExtraTurnSource {
    pub source_card: Entity,
    pub extra_turn_type: ExtraTurnType,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ExtraTurnType {
    Standard,
    CombatOnly,
    WithRestrictions(Vec<TurnRestriction>),
}
}

When a player would gain an extra turn:

  1. The extra turn is added to the queue
  2. When the current turn ends, the system checks the queue before advancing to the next player's turn
  3. If there are extra turns queued, the player specified in the queue takes their extra turn
  4. After all extra turns are complete, normal turn order resumes

Turn Restrictions

Certain cards can impose restrictions on turns, implemented through the TurnRestriction enum:

#![allow(unused)]
fn main() {
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TurnRestriction {
    NoUntap,
    NoUpkeep,
    NoDraw,
    NoMainPhase,
    NoCombat,
    MaxSpells(u32),
    NoActivatedAbilities,
    MustAttack,
    CantAttack,
    NoArtifactsEnchantmentsCreatures,
    LoseAtEndStep,
}
}

These restrictions can be associated with specific turns, including extra turns.

Skipped Turns

The system also tracks turns that should be skipped:

#![allow(unused)]
fn main() {
pub fn process_turn_transition(
    mut turn_manager: ResMut<TurnManager>,
    // Other parameters...
) {
    // Find the next active player
    let mut next_player_index = (turn_manager.active_player_index + 1) % turn_manager.player_order.len();
    let next_player = turn_manager.player_order[next_player_index];
    
    // Check for skipped turns
    if turn_manager.skipped_turns.contains(&next_player) {
        turn_manager.skipped_turns.remove(&next_player);
        // Skip to the player after the one who would skip
        next_player_index = (next_player_index + 1) % turn_manager.player_order.len();
    }
    
    // Check for extra turns first
    if !turn_manager.extra_turns.is_empty() {
        let (extra_turn_player, source) = turn_manager.extra_turns.pop_front().unwrap();
        // Process the extra turn...
    } else {
        // Set the next player as active
        turn_manager.active_player_index = next_player_index;
    }
    
    // Reset for new turn
    turn_manager.current_phase = Phase::Beginning(BeginningStep::Untap);
    turn_manager.turn_number += 1;
}
}

Additional Phase Modifications

The system supports other phase modifications:

  • Additional combat phases
  • Phase repetition
  • Phase reordering (in specific cases)
  • Phase duration modifications

Multiplayer Impact

Extra turns and turn modifications have significant impact in Commander:

  • Political considerations when taking extra turns
  • More targets for "target player skips their next turn" effects
  • Greater variance in game length due to turn manipulation
  • House rules regarding excessive turn manipulation

Implementation Details

The turn modification system integrates with the event system to ensure proper triggers occur:

#![allow(unused)]
fn main() {
pub fn extra_turn_system(
    mut commands: Commands,
    mut turn_manager: ResMut<TurnManager>,
    mut extra_turn_events: EventReader<ExtraTurnEvent>,
) {
    for event in extra_turn_events.iter() {
        turn_manager.extra_turns.push_back((event.player, event.source.clone()));
    }
}
}

Cards that grant or modify turns dispatch appropriate events that are handled by these systems.

Phase Management

Phase and Step Definitions

The Commander implementation uses a hierarchical structure of phases and steps that closely follows the official Magic rules:

#![allow(unused)]
fn main() {
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Phase {
    Beginning(BeginningStep),
    Precombat(PrecombatStep),
    Combat(CombatStep),
    Postcombat(PostcombatStep),
    Ending(EndingStep),
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BeginningStep {
    Untap,
    Upkeep,
    Draw,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PrecombatStep {
    Main,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CombatStep {
    Beginning,
    DeclareAttackers,
    DeclareBlockers,
    CombatDamage,
    End,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PostcombatStep {
    Main,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EndingStep {
    End,
    Cleanup,
}
}

Phase Transitions

Phase transitions are managed by the turn system and occur when:

  1. All players have passed priority with an empty stack
  2. Certain turn-based actions are completed
  3. Special effects cause phase skipping or addition

Phase-Specific Actions

Each phase and step has specific turn-based actions that occur at its beginning:

Beginning Phase

Untap Step

  • The active player untaps all permanents they control
  • No players receive priority during this step
  • "At the beginning of your untap step" triggered abilities trigger

Upkeep Step

  • "At the beginning of your upkeep" triggered abilities trigger
  • Players receive priority

Draw Step

  • Active player draws a card
  • "At the beginning of your draw step" triggered abilities trigger
  • Players receive priority

Main Phases

  • Active player may play land (once per turn)
  • Players may cast spells and activate abilities
  • No automatic actions occur

Combat Phase

Ending Phase

End Step

  • "At the beginning of your end step" triggered abilities trigger
  • Players receive priority

Cleanup Step

  • Active player discards down to maximum hand size
  • Damage is removed from permanents
  • "Until end of turn" effects end
  • Normally, no player receives priority during this step
    • If any state-based actions are performed or triggered abilities trigger, players do receive priority

Special Phase Handling

The phase system accounts for special cases:

#![allow(unused)]
fn main() {
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TurnRestriction {
    NoUntap,
    NoUpkeep,
    NoDraw,
    NoMainPhase,
    NoCombat,
    MaxSpells(u32),
    // Other restrictions
}
}

These phase restrictions can be applied through card effects and are managed by the TurnManager.

Multiplayer Turn Handling

Overview

Commander's multiplayer nature introduces special considerations for turn management, particularly regarding turn order, player elimination, and simultaneous effects. This document outlines how the turn system handles these multiplayer-specific scenarios.

Player Order Management

Turn order in Commander follows a clockwise rotation from a randomly determined starting player. The turn order is maintained in the TurnManager:

#![allow(unused)]
fn main() {
#[derive(Resource)]
pub struct TurnManager {
    // Other fields...
    pub player_order: Vec<Entity>,
}
}

At game initialization, the player order is randomized:

#![allow(unused)]
fn main() {
fn initialize_turn_order(
    mut commands: Commands,
    player_query: Query<Entity, With<Player>>,
) {
    let mut player_entities: Vec<Entity> = player_query.iter().collect();
    // Randomize the order
    player_entities.shuffle(&mut thread_rng());
    
    commands.insert_resource(TurnManager {
        player_order: player_entities,
        // Initialize other fields...
    });
}
}

Player Elimination Handling

When a player is eliminated from the game, the turn system must adjust the turn order and potentially the active player and priority indicators:

#![allow(unused)]
fn main() {
fn handle_player_elimination(
    mut commands: Commands,
    mut elimination_events: EventReader<PlayerEliminationEvent>,
    mut turn_manager: ResMut<TurnManager>,
) {
    for event in elimination_events.read() {
        let eliminated_player = event.player;
        
        // Remove player from turn order
        if let Some(pos) = turn_manager.player_order.iter().position(|&p| p == eliminated_player) {
            turn_manager.player_order.remove(pos);
            
            // Adjust current active and priority indices if needed
            if pos <= turn_manager.active_player_index {
                turn_manager.active_player_index = 
                    (turn_manager.active_player_index - 1) % turn_manager.player_order.len();
            }
            
            if pos <= turn_manager.priority_player_index {
                turn_manager.priority_player_index = 
                    (turn_manager.priority_player_index - 1) % turn_manager.player_order.len();
            }
        }
    }
}
}

Simultaneous Effects

In Commander, many effects can happen simultaneously across multiple players. The system handles these according to Magic's APNAP (Active Player, Non-Active Player) rule:

#![allow(unused)]
fn main() {
fn handle_simultaneous_effects(
    turn_manager: Res<TurnManager>,
    mut simultaneous_events: EventReader<SimultaneousEffectEvent>,
    mut commands: Commands,
) {
    let mut effect_queue = Vec::new();
    
    // Collect all simultaneous effects
    for event in simultaneous_events.read() {
        effect_queue.push((event.player, event.effect.clone()));
    }
    
    // Sort by APNAP order
    effect_queue.sort_by(|(player_a, _), (player_b, _)| {
        let active_player = turn_manager.player_order[turn_manager.active_player_index];
        
        if *player_a == active_player {
            return Ordering::Less;
        }
        if *player_b == active_player {
            return Ordering::Greater;
        }
        
        // For non-active players, compare positions in turn order
        let pos_a = turn_manager.player_order.iter().position(|&p| p == *player_a).unwrap();
        let pos_b = turn_manager.player_order.iter().position(|&p| p == *player_b).unwrap();
        pos_a.cmp(&pos_b)
    });
    
    // Process effects in sorted order
    for (player, effect) in effect_queue {
        // Process each effect...
    }
}
}

Multi-Player Variants

The turn system supports different Commander variants:

  • Free-for-All: Standard Commander with turn order passing clockwise
  • Two-Headed Giant: Teams share turns and life totals
  • Star: Five players with win conditions based on eliminating opposing "colors"
  • Archenemy: One player versus all others, with the Archenemy having access to scheme cards

Each variant may modify the TurnManager initialization and behavior:

#![allow(unused)]
fn main() {
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GameVariant {
    FreeForAll,
    TwoHeadedGiant,
    Star,
    Archenemy,
    // Other variants
}

// The variant affects turn handling
pub fn initialize_turn_manager(
    variant: GameVariant,
    players: Vec<Entity>,
) -> TurnManager {
    match variant {
        GameVariant::TwoHeadedGiant => {
            // Team members share turns
            // ...
        },
        GameVariant::Archenemy => {
            // Archenemy goes first
            // ...
        },
        _ => {
            // Standard initialization
            // ...
        }
    }
}
}

Multiplayer-Specific Considerations

The turn system accounts for multiplayer-specific mechanics:

  • Range of Influence: Limited in some variants (like Star)
  • Shared Team Turns: In variants like Two-Headed Giant
  • Table Politics: Support for game actions like voting
  • Monarch: Special designation that passes between players
  • Initiative: Tracking which player has the initiative in Undercity dungeon scenarios

Turn and Phase Tests

This section contains tests related to the turn structure and phase mechanics in the Commander format, with a focus on multiplayer interactions.

Multiplayer Turn Structure Tests

The Multiplayer Turn Structure Tests validate the proper handling of turns in a multiplayer environment, including:

  • Turn order in multiplayer games
  • Turn modification effects (extra turns, skipped turns)
  • Phase progression in various game states
  • Proper handling of priority in multiplayer scenarios
  • Player elimination effects on turn structure

These tests ensure that the multiplayer aspects of the Commander format's turn structure function correctly within the game engine.

Multiplayer Turn Structure Tests

Overview

This document outlines test cases for Commander's multiplayer turn structure, focusing on turn order, extra turns, and phase management in a multiplayer environment. These tests ensure the game properly handles turn-based interactions in a format that can have 3-6 players.

Test Case: Basic Turn Order

Test: Turn Order in Multiplayer Game

#![allow(unused)]
fn main() {
#[test]
fn test_multiplayer_turn_order() {
    // Test setup
    let mut app = App::new();
    app.add_plugins(MinimalPlugins)
       .add_systems(Update, (update_turn_order, next_turn, check_active_player));
       
    // Create a multiplayer game with 4 players
    let player1 = app.world.spawn((
        Player { id: 1 },
        PlayerName("Player 1".to_string()),
    )).id();
    
    let player2 = app.world.spawn((
        Player { id: 2 },
        PlayerName("Player 2".to_string()),
    )).id();
    
    let player3 = app.world.spawn((
        Player { id: 3 },
        PlayerName("Player 3".to_string()),
    )).id();
    
    let player4 = app.world.spawn((
        Player { id: 4 },
        PlayerName("Player 4".to_string()),
    )).id();
    
    // Set up turn order
    app.insert_resource(TurnOrder {
        players: vec![player1, player2, player3, player4],
        current_index: 0,
    });
    
    app.insert_resource(TurnManager {
        current_phase: Phase::Beginning(BeginningPhaseStep::Untap),
        active_player: player1,
    });
    
    // Validate initial active player
    let turn_manager = app.world.resource::<TurnManager>();
    assert_eq!(turn_manager.active_player, player1);
    
    // Execute a full turn and move to next player
    app.world.send_event(EndTurnEvent);
    app.update();
    
    // Verify active player changed to player 2
    let turn_manager = app.world.resource::<TurnManager>();
    assert_eq!(turn_manager.active_player, player2);
    
    // Execute another turn
    app.world.send_event(EndTurnEvent);
    app.update();
    
    // Verify active player changed to player 3
    let turn_manager = app.world.resource::<TurnManager>();
    assert_eq!(turn_manager.active_player, player3);
    
    // Simulate a full rotation
    app.world.send_event(EndTurnEvent);
    app.update();
    app.world.send_event(EndTurnEvent);
    app.update();
    
    // Should be back to player 1
    let turn_manager = app.world.resource::<TurnManager>();
    assert_eq!(turn_manager.active_player, player1);
}
}

Test Case: Player Elimination

Test: Removing a Player from Turn Order

#![allow(unused)]
fn main() {
#[test]
fn test_player_elimination() {
    // Test setup
    let mut app = App::new();
    app.add_plugins(MinimalPlugins)
       .add_systems(Update, (update_turn_order, handle_player_elimination, next_turn));
       
    // Create a multiplayer game with 4 players
    let player1 = app.world.spawn(Player { id: 1 }).id();
    let player2 = app.world.spawn(Player { id: 2 }).id();
    let player3 = app.world.spawn(Player { id: 3 }).id();
    let player4 = app.world.spawn(Player { id: 4 }).id();
    
    // Set up turn order
    app.insert_resource(TurnOrder {
        players: vec![player1, player2, player3, player4],
        current_index: 0,
    });
    
    app.insert_resource(TurnManager {
        current_phase: Phase::Beginning(BeginningPhaseStep::Untap),
        active_player: player1,
    });
    
    // Player 2 is eliminated
    app.world.send_event(PlayerEliminatedEvent { player: player2 });
    app.update();
    
    // Verify turn order adjusted
    let turn_order = app.world.resource::<TurnOrder>();
    assert_eq!(turn_order.players, vec![player1, player3, player4]);
    
    // Current player should still be player 1
    let turn_manager = app.world.resource::<TurnManager>();
    assert_eq!(turn_manager.active_player, player1);
    
    // End turn
    app.world.send_event(EndTurnEvent);
    app.update();
    
    // Verify active player skipped player 2 and is now player 3
    let turn_manager = app.world.resource::<TurnManager>();
    assert_eq!(turn_manager.active_player, player3);
}
}

Test: Eliminating the Active Player

#![allow(unused)]
fn main() {
#[test]
fn test_active_player_elimination() {
    // Test setup
    let mut app = App::new();
    app.add_plugins(MinimalPlugins)
       .add_systems(Update, (update_turn_order, handle_player_elimination, next_turn));
       
    // Create a multiplayer game with 4 players
    let player1 = app.world.spawn(Player { id: 1 }).id();
    let player2 = app.world.spawn(Player { id: 2 }).id();
    let player3 = app.world.spawn(Player { id: 3 }).id();
    let player4 = app.world.spawn(Player { id: 4 }).id();
    
    // Set up turn order
    app.insert_resource(TurnOrder {
        players: vec![player1, player2, player3, player4],
        current_index: 0,
    });
    
    app.insert_resource(TurnManager {
        current_phase: Phase::Beginning(BeginningPhaseStep::Untap),
        active_player: player1,
    });
    
    // Active player is eliminated
    app.world.send_event(PlayerEliminatedEvent { player: player1 });
    app.update();
    
    // Verify turn order adjusted and active player changed
    let turn_order = app.world.resource::<TurnOrder>();
    assert_eq!(turn_order.players, vec![player2, player3, player4]);
    
    let turn_manager = app.world.resource::<TurnManager>();
    assert_eq!(turn_manager.active_player, player2);
}
}

Test Case: Extra Turns

Test: Player Takes an Extra Turn

#![allow(unused)]
fn main() {
#[test]
fn test_extra_turn() {
    // Test setup
    let mut app = App::new();
    app.add_plugins(MinimalPlugins)
       .add_systems(Update, (update_turn_order, handle_extra_turns, next_turn));
       
    // Create a multiplayer game with 4 players
    let player1 = app.world.spawn(Player { id: 1 }).id();
    let player2 = app.world.spawn(Player { id: 2 }).id();
    let player3 = app.world.spawn(Player { id: 3 }).id();
    let player4 = app.world.spawn(Player { id: 4 }).id();
    
    // Set up turn order
    app.insert_resource(TurnOrder {
        players: vec![player1, player2, player3, player4],
        current_index: 0,
    });
    
    app.insert_resource(TurnManager {
        current_phase: Phase::Beginning(BeginningPhaseStep::Untap),
        active_player: player1,
    });
    
    app.insert_resource(ExtraTurns::default());
    
    // Player 1 gets an extra turn
    app.world.send_event(ExtraTurnEvent { 
        player: player1,
        count: 1,
        source: EntitySource { entity: Entity::PLACEHOLDER },
    });
    
    // End current turn
    app.world.send_event(EndTurnEvent);
    app.update();
    
    // Verify player 1 gets an extra turn instead of going to player 2
    let turn_manager = app.world.resource::<TurnManager>();
    assert_eq!(turn_manager.active_player, player1);
    
    // End extra turn
    app.world.send_event(EndTurnEvent);
    app.update();
    
    // Now it should go to player 2
    let turn_manager = app.world.resource::<TurnManager>();
    assert_eq!(turn_manager.active_player, player2);
}
}

Test: Multiple Extra Turns Across Players

#![allow(unused)]
fn main() {
#[test]
fn test_multiple_extra_turns() {
    // Test setup
    let mut app = App::new();
    app.add_plugins(MinimalPlugins)
       .add_systems(Update, (update_turn_order, handle_extra_turns, next_turn));
       
    // Create a multiplayer game with 4 players
    let player1 = app.world.spawn(Player { id: 1 }).id();
    let player2 = app.world.spawn(Player { id: 2 }).id();
    let player3 = app.world.spawn(Player { id: 3 }).id();
    let player4 = app.world.spawn(Player { id: 4 }).id();
    
    // Set up turn order
    app.insert_resource(TurnOrder {
        players: vec![player1, player2, player3, player4],
        current_index: 0,
    });
    
    app.insert_resource(TurnManager {
        current_phase: Phase::Beginning(BeginningPhaseStep::Untap),
        active_player: player1,
    });
    
    app.insert_resource(ExtraTurns::default());
    
    // Player 1 grants an extra turn to player 3
    app.world.send_event(ExtraTurnEvent { 
        player: player3,
        count: 1,
        source: EntitySource { entity: Entity::PLACEHOLDER },
    });
    
    // Player 2 grants an extra turn to themselves
    app.world.send_event(ExtraTurnEvent { 
        player: player2,
        count: 1,
        source: EntitySource { entity: Entity::PLACEHOLDER },
    });
    
    // End current turn (player 1)
    app.world.send_event(EndTurnEvent);
    app.update();
    
    // Regular turn order goes to player 2
    let turn_manager = app.world.resource::<TurnManager>();
    assert_eq!(turn_manager.active_player, player2);
    
    // End turn (player 2)
    app.world.send_event(EndTurnEvent);
    app.update();
    
    // Player 2's extra turn happens before moving to player 3
    let turn_manager = app.world.resource::<TurnManager>();
    assert_eq!(turn_manager.active_player, player2);
    
    // End extra turn (player 2)
    app.world.send_event(EndTurnEvent);
    app.update();
    
    // Now it's player 3's turn
    let turn_manager = app.world.resource::<TurnManager>();
    assert_eq!(turn_manager.active_player, player3);
    
    // End turn (player 3)
    app.world.send_event(EndTurnEvent);
    app.update();
    
    // Player 3 gets extra turn
    let turn_manager = app.world.resource::<TurnManager>();
    assert_eq!(turn_manager.active_player, player3);
}
}

Test Case: Phase Management

Test: Phase Progression Through a Turn

#![allow(unused)]
fn main() {
#[test]
fn test_phase_progression() {
    // Test setup
    let mut app = App::new();
    app.add_plugins(MinimalPlugins)
       .add_systems(Update, (update_phase, handle_phase_transitions));
       
    // Create player
    let player = app.world.spawn(Player {}).id();
    
    // Set up turn manager
    app.insert_resource(TurnManager {
        current_phase: Phase::Beginning(BeginningPhaseStep::Untap),
        active_player: player,
    });
    
    // Progress through beginning phase
    app.update();
    assert_eq!(app.world.resource::<TurnManager>().current_phase, Phase::Beginning(BeginningPhaseStep::Untap));
    
    app.world.send_event(NextPhaseEvent);
    app.update();
    assert_eq!(app.world.resource::<TurnManager>().current_phase, Phase::Beginning(BeginningPhaseStep::Upkeep));
    
    app.world.send_event(NextPhaseEvent);
    app.update();
    assert_eq!(app.world.resource::<TurnManager>().current_phase, Phase::Beginning(BeginningPhaseStep::Draw));
    
    // Move to main phase
    app.world.send_event(NextPhaseEvent);
    app.update();
    assert_eq!(app.world.resource::<TurnManager>().current_phase, Phase::PreCombatMain);
    
    // Move to combat
    app.world.send_event(NextPhaseEvent);
    app.update();
    assert_eq!(app.world.resource::<TurnManager>().current_phase, Phase::Combat(CombatStep::Beginning));
    
    // Cycle through combat steps
    app.world.send_event(NextPhaseEvent);
    app.update();
    assert_eq!(app.world.resource::<TurnManager>().current_phase, Phase::Combat(CombatStep::DeclareAttackers));
    
    app.world.send_event(NextPhaseEvent);
    app.update();
    assert_eq!(app.world.resource::<TurnManager>().current_phase, Phase::Combat(CombatStep::DeclareBlockers));
    
    app.world.send_event(NextPhaseEvent);
    app.update();
    assert_eq!(app.world.resource::<TurnManager>().current_phase, Phase::Combat(CombatStep::CombatDamage));
    
    app.world.send_event(NextPhaseEvent);
    app.update();
    assert_eq!(app.world.resource::<TurnManager>().current_phase, Phase::Combat(CombatStep::End));
    
    // Move to post-combat main
    app.world.send_event(NextPhaseEvent);
    app.update();
    assert_eq!(app.world.resource::<TurnManager>().current_phase, Phase::PostCombatMain);
    
    // Move to end step
    app.world.send_event(NextPhaseEvent);
    app.update();
    assert_eq!(app.world.resource::<TurnManager>().current_phase, Phase::Ending(EndingPhaseStep::End));
    
    app.world.send_event(NextPhaseEvent);
    app.update();
    assert_eq!(app.world.resource::<TurnManager>().current_phase, Phase::Ending(EndingPhaseStep::Cleanup));
}
}

Test: Turn-Based Actions in Each Phase

#![allow(unused)]
fn main() {
#[test]
fn test_turn_based_actions() {
    // Test setup
    let mut app = App::new();
    app.add_plugins(MinimalPlugins)
       .add_systems(Update, (handle_untap_step, handle_draw_step, handle_phase_transitions));
       
    // Create player with permanents
    let player = app.world.spawn((
        Player {},
        Hand::default(),
    )).id();
    
    // Create some tapped permanents
    let permanent1 = app.world.spawn((
        Card { name: "Permanent 1".to_string() },
        Permanent,
        Status { tapped: true },
        Controller { player },
    )).id();
    
    let permanent2 = app.world.spawn((
        Card { name: "Permanent 2".to_string() },
        Permanent,
        Status { tapped: true },
        Controller { player },
    )).id();
    
    // Set up turn manager in untap step
    app.insert_resource(TurnManager {
        current_phase: Phase::Beginning(BeginningPhaseStep::Untap),
        active_player: player,
    });
    
    // Process untap step
    app.update();
    
    // Verify permanents were untapped
    let status1 = app.world.get::<Status>(permanent1).unwrap();
    let status2 = app.world.get::<Status>(permanent2).unwrap();
    assert!(!status1.tapped);
    assert!(!status2.tapped);
    
    // Move to draw step
    app.world.resource_mut::<TurnManager>().current_phase = Phase::Beginning(BeginningPhaseStep::Draw);
    
    // Create library with cards
    let card1 = app.world.spawn((
        Card { name: "Card 1".to_string() },
        Zone::Library,
        Owner { player },
    )).id();
    
    let library = app.world.spawn((
        Library { owner: player },
        Cards { entities: vec![card1] },
    )).id();
    
    // Process draw step
    app.update();
    
    // Verify card was drawn
    assert_eq!(app.world.get::<Zone>(card1).unwrap(), &Zone::Hand);
    let hand = app.world.get::<Hand>(player).unwrap();
    assert_eq!(hand.cards.len(), 1);
    assert!(hand.cards.contains(&card1));
}
}

Test Case: Priority System

Test: Priority Passing in Turn Order

#![allow(unused)]
fn main() {
#[test]
fn test_priority_passing() {
    // Test setup
    let mut app = App::new();
    app.add_plugins(MinimalPlugins)
       .add_systems(Update, (handle_priority, update_turn_order));
       
    // Create a multiplayer game with 4 players
    let player1 = app.world.spawn(Player { id: 1 }).id();
    let player2 = app.world.spawn(Player { id: 2 }).id();
    let player3 = app.world.spawn(Player { id: 3 }).id();
    let player4 = app.world.spawn(Player { id: 4 }).id();
    
    // Set up turn order
    app.insert_resource(TurnOrder {
        players: vec![player1, player2, player3, player4],
        current_index: 0,
    });
    
    app.insert_resource(TurnManager {
        current_phase: Phase::PreCombatMain,
        active_player: player1,
    });
    
    app.insert_resource(PrioritySystem {
        has_priority: player1,
        stack_is_empty: true,
        all_players_passed: false,
    });
    
    // Active player passes priority
    app.world.send_event(PriorityPassedEvent { player: player1 });
    app.update();
    
    // Check priority passed to next player
    let priority = app.world.resource::<PrioritySystem>();
    assert_eq!(priority.has_priority, player2);
    
    // Second player passes
    app.world.send_event(PriorityPassedEvent { player: player2 });
    app.update();
    
    // Check priority passed to next player
    let priority = app.world.resource::<PrioritySystem>();
    assert_eq!(priority.has_priority, player3);
    
    // Third player passes
    app.world.send_event(PriorityPassedEvent { player: player3 });
    app.update();
    
    // Check priority passed to last player
    let priority = app.world.resource::<PrioritySystem>();
    assert_eq!(priority.has_priority, player4);
    
    // Last player passes
    app.world.send_event(PriorityPassedEvent { player: player4 });
    app.update();
    
    // All players passed, should move to next phase
    let priority = app.world.resource::<PrioritySystem>();
    assert!(priority.all_players_passed);
}
}

Test: Priority After Casting a Spell

#![allow(unused)]
fn main() {
#[test]
fn test_priority_after_spell() {
    // Test setup
    let mut app = App::new();
    app.add_plugins(MinimalPlugins)
       .add_systems(Update, (handle_priority, handle_spell_cast));
       
    // Create a multiplayer game with 4 players
    let player1 = app.world.spawn(Player { id: 1 }).id();
    let player2 = app.world.spawn(Player { id: 2 }).id();
    let player3 = app.world.spawn(Player { id: 3 }).id();
    let player4 = app.world.spawn(Player { id: 4 }).id();
    
    // Set up turn order
    app.insert_resource(TurnOrder {
        players: vec![player1, player2, player3, player4],
        current_index: 0,
    });
    
    app.insert_resource(TurnManager {
        current_phase: Phase::PreCombatMain,
        active_player: player1,
    });
    
    app.insert_resource(PrioritySystem {
        has_priority: player1,
        stack_is_empty: true,
        all_players_passed: false,
    });
    
    // Create a spell
    let spell = app.world.spawn((
        Card { name: "Instant Spell".to_string() },
        Spell,
        Owner { player: player1 },
    )).id();
    
    // Player 1 casts a spell
    app.world.send_event(SpellCastEvent { 
        caster: player1,
        spell: spell,
    });
    app.update();
    
    // Verify spell is on the stack
    let stack = app.world.resource::<Stack>();
    assert!(!stack.is_empty());
    
    // Stack is not empty and priority passed to active player again
    let priority = app.world.resource::<PrioritySystem>();
    assert_eq!(priority.has_priority, player1);
    assert!(!priority.stack_is_empty);
    
    // All players need to pass again for spell to resolve
    app.world.send_event(PriorityPassedEvent { player: player1 });
    app.update();
    app.world.send_event(PriorityPassedEvent { player: player2 });
    app.update();
    app.world.send_event(PriorityPassedEvent { player: player3 });
    app.update();
    app.world.send_event(PriorityPassedEvent { player: player4 });
    app.update();
    
    // Spell should resolve and priority goes back to active player
    let stack = app.world.resource::<Stack>();
    assert!(stack.is_empty());
    
    let priority = app.world.resource::<PrioritySystem>();
    assert_eq!(priority.has_priority, player1);
}
}

These test cases ensure the Commander game engine properly handles multiplayer turn structure, including phase management, turn order, and priority passing in multiplayer scenarios.

Commander-Specific Stack and Priority

Overview

This section covers the Commander-specific aspects of the stack and priority system in Rummage. For core stack and priority mechanics that apply to all formats, see the MTG Core Stack and Priority documentation.

Commander Stack Extensions

The Commander format extends the basic MTG stack and priority system with these key features:

  1. Multiplayer Priority - Modified priority passing for games with 3+ players
  2. Commander Casting - Special handling for casting commanders from the command zone
  3. Political Interaction - Support for verbal agreements and diplomacy during priority windows

Contents

Key Commander Stack Features

Multiplayer Priority Flow

In multiplayer Commander games, priority passes in turn order starting with the active player:

#![allow(unused)]
fn main() {
pub fn get_next_priority_player(
    current_player: Entity,
    player_order: &Vec<Entity>,
) -> Entity {
    let current_index = player_order.iter().position(|&p| p == current_player).unwrap();
    let next_index = (current_index + 1) % player_order.len();
    player_order[next_index]
}
}

This system handles priority passing among all players in the game, ensuring each player has an opportunity to respond before a spell or ability resolves.

Commander Casting from Command Zone

When a player casts their commander from the command zone, special rules apply:

  • The commander tax increases the cost by {2} for each previous cast from the command zone
  • The commander moves from the command zone to the stack
  • All players have an opportunity to respond before it resolves

Political Considerations

While not encoded in the rules, the Commander format involves political dynamics:

  • Players may make deals about actions they'll take when they have priority
  • Verbal agreements can influence who players target with spells or attacks
  • Priority windows are important moments for diplomatic negotiation

Command Zone Integration

The stack system integrates closely with the Command Zone implementation to handle:

  • Moving commanders between the command zone and the stack
  • Tracking commander tax for casting costs
  • Handling commanders with alternative casting methods

Commander stack and priority interact with several other systems:

Testing

Comprehensive tests verify correct handling of stack and priority in multiplayer Commander games, with special attention to:

  • Correct priority order in multiplayer scenarios
  • Commander tax application when casting from the command zone
  • Interaction of simultaneous triggered abilities from multiple players

Next: Stack Implementation

Stack Implementation

Priority Passing

Special Timing Rules

Stack and Priority Tests

This section contains tests related to the stack and priority system in the Commander format.

Stack Resolution Tests

The Stack Resolution Tests validate the proper functioning of the stack in various scenarios, including:

  • Proper ordering of spells and abilities on the stack
  • Resolution of the stack in LIFO (Last In, First Out) order
  • Handling of counterspells and other stack-modifying effects
  • Split second and other special timing rules
  • Multiplayer priority passing
  • Stack interactions with state-based actions

These tests ensure that the stack and priority system conform to Magic: The Gathering rules and behave correctly within the multiplayer context of the Commander format.

Stack Resolution Tests

Overview

This document outlines test cases for the stack system in Commander format. These tests ensure that the stack operates correctly, spells and abilities resolve in the right order, and that priority passing works as expected in this multiplayer format.

Test Case: Basic Stack Resolution

Test: Last In, First Out Resolution Order

#![allow(unused)]
fn main() {
#[test]
fn test_lifo_resolution() {
    // Test setup
    let mut app = App::new();
    app.add_plugins(MinimalPlugins)
       .add_systems(Update, (handle_stack_resolution, update_game_state));
       
    // Create player
    let player = app.world.spawn(Player {}).id();
    
    // Create spells for the stack
    let spell1 = app.world.spawn((
        Card { name: "First Spell".to_string() },
        Spell,
        StackPosition(0),
    )).id();
    
    let spell2 = app.world.spawn((
        Card { name: "Second Spell".to_string() },
        Spell,
        StackPosition(1),
    )).id();
    
    let spell3 = app.world.spawn((
        Card { name: "Third Spell".to_string() },
        Spell,
        StackPosition(2),
    )).id();
    
    // Set up stack with spells in order
    app.insert_resource(Stack {
        items: vec![spell1, spell2, spell3],
    });
    
    // Track resolution order
    app.insert_resource(ResolutionOrder { items: Vec::new() });
    
    // All players pass priority
    app.insert_resource(PrioritySystem {
        has_priority: player,
        stack_is_empty: false,
        all_players_passed: true,
    });
    
    // Resolve stack
    app.update();
    
    // Verify resolution happened in LIFO order
    let resolution_order = app.world.resource::<ResolutionOrder>();
    assert_eq!(resolution_order.items, vec![spell3, spell2, spell1]);
}
}

Test Case: Interrupting Stack Resolution

Test: Adding to the Stack During Resolution

#![allow(unused)]
fn main() {
#[test]
fn test_stack_interruption() {
    // Test setup
    let mut app = App::new();
    app.add_plugins(MinimalPlugins)
       .add_systems(Update, (handle_stack_resolution, handle_triggered_abilities));
       
    // Create player
    let player = app.world.spawn(Player {}).id();
    
    // Create an ability that will trigger when a spell resolves
    let permanent = app.world.spawn((
        Card { name: "Ability Source".to_string() },
        Permanent,
        TriggeredAbility {
            trigger: Trigger::SpellResolution,
            ability: AbilityType::CreateEffect,
        },
        Controller { player },
    )).id();
    
    // Create spells for the stack
    let spell1 = app.world.spawn((
        Card { name: "First Spell".to_string() },
        Spell,
        StackPosition(0),
    )).id();
    
    let spell2 = app.world.spawn((
        Card { name: "Second Spell".to_string() },
        Spell,
        StackPosition(1),
    )).id();
    
    // Set up stack with spells
    app.insert_resource(Stack {
        items: vec![spell1, spell2],
    });
    
    // All players pass priority
    app.insert_resource(PrioritySystem {
        has_priority: player,
        stack_is_empty: false,
        all_players_passed: true,
    });
    
    // Partially resolve stack (just the top spell)
    app.update();
    
    // Verify the triggered ability was put on the stack
    let stack = app.world.resource::<Stack>();
    assert_eq!(stack.items.len(), 2); // Original spell1 + new triggered ability
    
    // Check that the new ability is at the top of the stack
    let top_item = stack.items.last().unwrap();
    assert!(app.world.get::<TriggeredAbility>(*top_item).is_some());
}
}

Test Case: Priority Passing in Multiplayer

Test: Full Round of Priority After Spell Cast

#![allow(unused)]
fn main() {
#[test]
fn test_multiplayer_priority() {
    // Test setup
    let mut app = App::new();
    app.add_plugins(MinimalPlugins)
       .add_systems(Update, (handle_priority, handle_spell_cast));
       
    // Create multiple players
    let player1 = app.world.spawn(Player { id: 1 }).id();
    let player2 = app.world.spawn(Player { id: 2 }).id();
    let player3 = app.world.spawn(Player { id: 3 }).id();
    let player4 = app.world.spawn(Player { id: 4 }).id();
    
    // Set up turn order
    app.insert_resource(TurnOrder {
        players: vec![player1, player2, player3, player4],
        current_index: 0,
    });
    
    app.insert_resource(TurnManager {
        current_phase: Phase::PreCombatMain,
        active_player: player1,
    });
    
    // Create a spell
    let spell = app.world.spawn((
        Card { name: "Test Spell".to_string() },
        Spell,
        Owner { player: player1 },
    )).id();
    
    // Player 1 has priority initially
    app.insert_resource(PrioritySystem {
        has_priority: player1,
        stack_is_empty: true,
        all_players_passed: false,
    });
    
    // Player 1 casts a spell
    app.world.send_event(SpellCastEvent {
        caster: player1,
        spell: spell,
    });
    app.update();
    
    // Verify spell is on stack
    let stack = app.world.resource::<Stack>();
    assert_eq!(stack.items.len(), 1);
    assert_eq!(stack.items[0], spell);
    
    // Verify priority returned to active player
    let priority = app.world.resource::<PrioritySystem>();
    assert_eq!(priority.has_priority, player1);
    
    // Player 1 passes priority
    app.world.send_event(PriorityPassedEvent { player: player1 });
    app.update();
    
    // Priority passes to player 2
    let priority = app.world.resource::<PrioritySystem>();
    assert_eq!(priority.has_priority, player2);
    
    // Player 2 has opportunity to respond
    let response_spell = app.world.spawn((
        Card { name: "Response Spell".to_string() },
        Spell,
        Owner { player: player2 },
    )).id();
    
    // Player 2 casts a response
    app.world.send_event(SpellCastEvent {
        caster: player2,
        spell: response_spell,
    });
    app.update();
    
    // Verify both spells are on stack with response on top
    let stack = app.world.resource::<Stack>();
    assert_eq!(stack.items.len(), 2);
    assert_eq!(stack.items[1], response_spell);
    
    // Verify priority returned to player 2
    let priority = app.world.resource::<PrioritySystem>();
    assert_eq!(priority.has_priority, player2);
    
    // All players need to pass again for anything to resolve
    app.world.send_event(PriorityPassedEvent { player: player2 });
    app.update();
    app.world.send_event(PriorityPassedEvent { player: player3 });
    app.update();
    app.world.send_event(PriorityPassedEvent { player: player4 });
    app.update();
    app.world.send_event(PriorityPassedEvent { player: player1 });
    app.update();
    
    // Top spell should resolve
    app.update();
    
    // Verify response spell resolved
    let stack = app.world.resource::<Stack>();
    assert_eq!(stack.items.len(), 1);
    assert_eq!(stack.items[0], spell);
}
}

Test Case: Split Second and Interrupts

Test: Split Second Prevents Further Responses

#![allow(unused)]
fn main() {
#[test]
fn test_split_second() {
    // Test setup
    let mut app = App::new();
    app.add_plugins(MinimalPlugins)
       .add_systems(Update, (handle_priority, handle_spell_cast, validate_spell_cast));
       
    // Create players
    let player1 = app.world.spawn(Player { id: 1 }).id();
    let player2 = app.world.spawn(Player { id: 2 }).id();
    
    // Set up turn order
    app.insert_resource(TurnOrder {
        players: vec![player1, player2],
        current_index: 0,
    });
    
    app.insert_resource(TurnManager {
        current_phase: Phase::PreCombatMain,
        active_player: player1,
    });
    
    // Create a split second spell
    let split_second_spell = app.world.spawn((
        Card { name: "Split Second Spell".to_string() },
        Spell,
        SplitSecond,
        Owner { player: player1 },
    )).id();
    
    // Player 1 has priority initially
    app.insert_resource(PrioritySystem {
        has_priority: player1,
        stack_is_empty: true,
        all_players_passed: false,
    });
    
    // Player 1 casts split second spell
    app.world.send_event(SpellCastEvent {
        caster: player1,
        spell: split_second_spell,
    });
    app.update();
    
    // Verify split second spell is on stack
    let stack = app.world.resource::<Stack>();
    assert_eq!(stack.items.len(), 1);
    assert_eq!(stack.items[0], split_second_spell);
    
    // Create a response spell
    let response_spell = app.world.spawn((
        Card { name: "Response Spell".to_string() },
        Spell,
        Owner { player: player2 },
    )).id();
    
    // Priority passes to player 2
    app.world.resource_mut::<PrioritySystem>().has_priority = player2;
    
    // Player 2 attempts to cast a response
    app.world.send_event(SpellCastEvent {
        caster: player2,
        spell: response_spell,
    });
    app.update();
    
    // Verify response spell was prevented from being cast
    let stack = app.world.resource::<Stack>();
    assert_eq!(stack.items.len(), 1); // Still only the split second spell
    
    // Only mana abilities and special actions can be taken
    let mana_ability = app.world.spawn((
        Card { name: "Mana Source".to_string() },
        ManaAbility,
        Owner { player: player2 },
    )).id();
    
    // Player 2 activates a mana ability
    app.world.send_event(ActivateManaAbilityEvent {
        player: player2,
        ability: mana_ability,
    });
    app.update();
    
    // Verify mana ability was allowed
    assert!(app.world.resource::<Events<ManaProducedEvent>>().is_empty() == false);
}
}

Test Case: Counterspells and Responses

Test: Counterspell on the Stack

#![allow(unused)]
fn main() {
#[test]
fn test_counterspell() {
    // Test setup
    let mut app = App::new();
    app.add_plugins(MinimalPlugins)
       .add_systems(Update, (handle_stack_resolution, handle_spell_cast, handle_counterspell));
       
    // Create player
    let player1 = app.world.spawn(Player { id: 1 }).id();
    let player2 = app.world.spawn(Player { id: 2 }).id();
    
    // Create a target spell
    let target_spell = app.world.spawn((
        Card { name: "Target Spell".to_string() },
        Spell,
        Owner { player: player1 },
    )).id();
    
    // Create a counterspell
    let counterspell = app.world.spawn((
        Card { name: "Counterspell".to_string() },
        Spell,
        CounterspellEffect,
        Owner { player: player2 },
    )).id();
    
    // Put target spell on stack
    app.insert_resource(Stack {
        items: vec![target_spell],
    });
    
    // Player 2 casts counterspell targeting the spell
    app.world.send_event(SpellCastEvent {
        caster: player2,
        spell: counterspell,
    });
    
    // Set up targets for counterspell
    app.world.spawn((
        Target {
            source: counterspell,
            targets: vec![TargetInfo {
                entity: target_spell,
                target_type: TargetType::Spell,
            }],
        },
    ));
    app.update();
    
    // Verify both spells on stack with counterspell on top
    let stack = app.world.resource::<Stack>();
    assert_eq!(stack.items.len(), 2);
    assert_eq!(stack.items[1], counterspell);
    
    // All players pass priority
    app.insert_resource(PrioritySystem {
        has_priority: player1,
        stack_is_empty: false,
        all_players_passed: true,
    });
    
    // Resolve counterspell
    app.update();
    
    // Verify target spell was countered and removed from stack
    let stack = app.world.resource::<Stack>();
    assert!(stack.items.is_empty());
    
    // Verify target spell moved to graveyard
    assert_eq!(app.world.get::<Zone>(target_spell).unwrap(), &Zone::Graveyard);
}
}

Test: Uncounterable Spell

#![allow(unused)]
fn main() {
#[test]
fn test_uncounterable_spell() {
    // Test setup
    let mut app = App::new();
    app.add_plugins(MinimalPlugins)
       .add_systems(Update, (handle_stack_resolution, handle_spell_cast, handle_counterspell));
       
    // Create player
    let player1 = app.world.spawn(Player { id: 1 }).id();
    let player2 = app.world.spawn(Player { id: 2 }).id();
    
    // Create an uncounterable spell
    let uncounterable_spell = app.world.spawn((
        Card { name: "Uncounterable Spell".to_string() },
        Spell,
        CannotBeCountered,
        Owner { player: player1 },
    )).id();
    
    // Create a counterspell
    let counterspell = app.world.spawn((
        Card { name: "Counterspell".to_string() },
        Spell,
        CounterspellEffect,
        Owner { player: player2 },
    )).id();
    
    // Put uncounterable spell on stack
    app.insert_resource(Stack {
        items: vec![uncounterable_spell],
    });
    
    // Player 2 casts counterspell targeting the spell
    app.world.send_event(SpellCastEvent {
        caster: player2,
        spell: counterspell,
    });
    
    // Set up targets for counterspell
    app.world.spawn((
        Target {
            source: counterspell,
            targets: vec![TargetInfo {
                entity: uncounterable_spell,
                target_type: TargetType::Spell,
            }],
        },
    ));
    app.update();
    
    // Verify both spells on stack
    let stack = app.world.resource::<Stack>();
    assert_eq!(stack.items.len(), 2);
    
    // All players pass priority
    app.insert_resource(PrioritySystem {
        has_priority: player1,
        stack_is_empty: false,
        all_players_passed: true,
    });
    
    // Resolve counterspell
    app.update();
    
    // Verify uncounterable spell remains on stack
    let stack = app.world.resource::<Stack>();
    assert_eq!(stack.items.len(), 1);
    assert_eq!(stack.items[0], uncounterable_spell);
    
    // Verify counterspell went to graveyard
    assert_eq!(app.world.get::<Zone>(counterspell).unwrap(), &Zone::Graveyard);
}
}

Test Case: Triggered Abilities and The Stack

Test: Multiple Triggered Abilities Order

#![allow(unused)]
fn main() {
#[test]
fn test_multiple_triggers() {
    // Test setup
    let mut app = App::new();
    app.add_plugins(MinimalPlugins)
       .add_systems(Update, (handle_triggered_abilities, order_triggered_abilities));
       
    // Create players
    let player1 = app.world.spawn(Player { id: 1 }).id();
    let player2 = app.world.spawn(Player { id: 2 }).id();
    
    // Turn order
    app.insert_resource(TurnOrder {
        players: vec![player1, player2],
        current_index: 0,
    });
    
    app.insert_resource(TurnManager {
        current_phase: Phase::Beginning(BeginningPhaseStep::Upkeep),
        active_player: player1,
    });
    
    // Create permanents with upkeep triggers for both players
    let permanent1 = app.world.spawn((
        Card { name: "Permanent 1".to_string() },
        Permanent,
        TriggeredAbility {
            trigger: Trigger::Upkeep(TriggerController::ActivePlayer),
            ability: AbilityType::DrawCard,
        },
        Controller { player: player1 },
    )).id();
    
    let permanent2 = app.world.spawn((
        Card { name: "Permanent 2".to_string() },
        Permanent,
        TriggeredAbility {
            trigger: Trigger::Upkeep(TriggerController::ActivePlayer),
            ability: AbilityType::GainLife(1),
        },
        Controller { player: player1 },
    )).id();
    
    let permanent3 = app.world.spawn((
        Card { name: "Permanent 3".to_string() },
        Permanent,
        TriggeredAbility {
            trigger: Trigger::Upkeep(TriggerController::ActivePlayer),
            ability: AbilityType::LoseLife(1),
        },
        Controller { player: player2 },
    )).id();
    
    // Trigger abilities
    app.world.send_event(PhaseChangedEvent {
        new_phase: Phase::Beginning(BeginningPhaseStep::Upkeep),
    });
    app.update();
    
    // Verify triggers were put on stack
    let stack = app.world.resource::<Stack>();
    assert_eq!(stack.items.len(), 3);
    
    // Verify triggers from active player go on stack in APNAP order
    // (Active Player, Non-Active Player)
    let stack_items = &stack.items;
    
    // Active player's abilities should be first (in controller's choice order)
    let first_two_controllers: Vec<Entity> = stack_items.iter()
        .take(2)
        .map(|e| app.world.get::<Controller>(*e).unwrap().player)
        .collect();
    
    // The first two should belong to player1 (active player)
    assert_eq!(first_two_controllers, vec![player1, player1]);
    
    // The last ability should belong to player2
    let last_controller = app.world.get::<Controller>(stack_items[2]).unwrap().player;
    assert_eq!(last_controller, player2);
}
}

Test Case: Stack and State-Based Actions

Test: State-Based Actions Between Stack Resolutions

#![allow(unused)]
fn main() {
#[test]
fn test_state_based_actions_and_stack() {
    // Test setup
    let mut app = App::new();
    app.add_plugins(MinimalPlugins)
       .add_systems(Update, (handle_stack_resolution, handle_state_based_actions));
       
    // Create player and creature
    let player = app.world.spawn(Player {}).id();
    
    let creature = app.world.spawn((
        Card { name: "Creature".to_string() },
        Creature { power: 3, toughness: 3 },
        Health { current: 3, maximum: 3 },
        Zone::Battlefield,
        Owner { player },
    )).id();
    
    // Create damage spell
    let damage_spell = app.world.spawn((
        Card { name: "Lightning Bolt".to_string() },
        Spell,
        DamageEffect { amount: 3 },
        Owner { player },
    )).id();
    
    // Create spell target
    app.world.spawn((
        Target {
            source: damage_spell,
            targets: vec![TargetInfo {
                entity: creature,
                target_type: TargetType::Creature,
            }],
        },
    ));
    
    // Create heal spell that will be cast in response
    let heal_spell = app.world.spawn((
        Card { name: "Healing Touch".to_string() },
        Spell,
        HealEffect { amount: 3 },
        Owner { player },
    )).id();
    
    // Create heal target
    app.world.spawn((
        Target {
            source: heal_spell,
            targets: vec![TargetInfo {
                entity: creature,
                target_type: TargetType::Creature,
            }],
        },
    ));
    
    // Put spells on stack in order (heal on top, will resolve first)
    app.insert_resource(Stack {
        items: vec![damage_spell, heal_spell],
    });
    
    // Setup priority for resolution
    app.insert_resource(PrioritySystem {
        has_priority: player,
        stack_is_empty: false,
        all_players_passed: true,
    });
    
    // Resolve heal spell
    app.update();
    
    // Verify creature healed to full
    let health = app.world.get::<Health>(creature).unwrap();
    assert_eq!(health.current, 3);
    
    // Still one spell on stack
    let stack = app.world.resource::<Stack>();
    assert_eq!(stack.items.len(), 1);
    
    // Resolve damage spell
    app.update();
    
    // Verify creature took damage
    let health = app.world.get::<Health>(creature).unwrap();
    assert_eq!(health.current, 0);
    
    // Verify stack is empty
    let stack = app.world.resource::<Stack>();
    assert!(stack.items.is_empty());
    
    // Apply state-based actions check
    app.update();
    
    // Verify creature died due to state-based actions
    assert_eq!(app.world.get::<Zone>(creature).unwrap(), &Zone::Graveyard);
}
}

These test cases ensure the stack resolution system functions correctly, handling priority passing, triggered abilities, counterspells, and state-based actions in accordance with Commander's rules.

Commander-Specific Combat Mechanics

Overview

This section covers the Commander-specific combat mechanics in the Rummage game engine. For core combat mechanics that apply to all formats, see the MTG Core Combat documentation.

Commander Combat Extensions

Commander extends the basic MTG combat system with these key mechanics:

  1. Commander Damage - A player who has been dealt 21 or more combat damage by the same commander loses the game
  2. Multiplayer Combat - Special considerations for attacking and blocking in a multiplayer environment
  3. Commander-specific Combat Abilities - Handling abilities that interact with commander status

Contents

Key Commander Combat Features

Commander Damage Tracking

The system tracks commander damage separately from regular damage:

#![allow(unused)]
fn main() {
#[derive(Component)]
pub struct CommanderDamageTracker {
    // Maps commander entity -> damage dealt to player
    pub damage_taken: HashMap<Entity, u32>,
}
}

When a player takes 21 or more combat damage from the same commander, they lose the game regardless of their life total.

Multiplayer Combat Dynamics

Commander's multiplayer format introduces unique combat dynamics:

  • Players can attack any opponent, not just the player to their left/right
  • Political considerations affect attack and block decisions
  • Players can make deals regarding combat (though these aren't enforced by the game rules)

Combat in Multiplayer Politics

Combat is central to the political dynamics of Commander:

  • Attacks signal aggression and can lead to retaliation
  • Defending other players can forge temporary alliances
  • Commander damage creates an additional threat vector beyond life totals

Commander combat interacts with several other systems:

Testing

The tests directory contains comprehensive test cases for validating commander-specific combat mechanics, with special focus on commander damage tracking and multiplayer scenarios.


Next: Commander Damage

Combat Phases

Overview

The Combat Phase in Commander follows the standard Magic: The Gathering combat sequence, with special considerations for the multiplayer nature of the format. This document provides an overview of the combat phase structure and links to detailed implementation documentation for each step.

Combat Phase Sequence

The Combat Phase consists of five distinct steps:

  1. Beginning of Combat Step - The phase begins and "at beginning of combat" triggered abilities go on the stack
  2. Declare Attackers Step - The active player declares attackers and "when attacks" triggered abilities go on the stack
  3. Declare Blockers Step - Each defending player declares blockers and "when blocks/blocked" triggered abilities go on the stack
  4. Combat Damage Step - Combat damage is assigned and dealt, and "when deals damage" triggered abilities go on the stack
  5. End of Combat Step - The phase ends and "at end of combat" triggered abilities go on the stack

Implementation Architecture

Combat phases are implemented using a combination of phase-specific systems and a shared combat state:

#![allow(unused)]
fn main() {
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum CombatStep {
    Beginning,
    DeclareAttackers,
    DeclareBlockers,
    FirstStrike,
    CombatDamage,
    End,
}

// Systems for handling different combat steps
pub fn beginning_of_combat_system(
    mut combat_system: ResMut<CombatSystem>,
    turn_manager: Res<TurnManager>,
    mut commands: Commands,
    // Other system parameters
) {
    // Implementation
}

pub fn declare_attackers_system(
    mut combat_system: ResMut<CombatSystem>,
    turn_manager: Res<TurnManager>,
    // Other system parameters
) {
    // Implementation
}

// And so on for other combat steps
}

Detailed Phase Documentation

Each combat step has its own specialized implementation with unique rules and edge cases:

Combat Phase Transitions

The transition between combat steps is managed by the TurnManager, which ensures that:

  1. Each step is processed in the correct order
  2. Priority is passed to all players in turn order during each step
  3. The stack is emptied before proceeding to the next step
  4. Any special effects that modify the combat phase flow are properly handled

Integration with Turn Structure

The combat phase is integrated into the overall turn structure via the TurnManager:

#![allow(unused)]
fn main() {
impl TurnManager {
    pub fn advance_phase(&mut self) -> Result<Phase, TurnError> {
        self.current_phase = match self.current_phase {
            // Other phases...
            Phase::Combat(CombatStep::Beginning) => Phase::Combat(CombatStep::DeclareAttackers),
            Phase::Combat(CombatStep::DeclareAttackers) => Phase::Combat(CombatStep::DeclareBlockers),
            Phase::Combat(CombatStep::DeclareBlockers) => {
                if self.has_first_strike_creatures() {
                    Phase::Combat(CombatStep::FirstStrike)
                } else {
                    Phase::Combat(CombatStep::CombatDamage)
                }
            },
            Phase::Combat(CombatStep::FirstStrike) => Phase::Combat(CombatStep::CombatDamage),
            Phase::Combat(CombatStep::CombatDamage) => Phase::Combat(CombatStep::End),
            Phase::Combat(CombatStep::End) => Phase::Postcombat(PostcombatStep::Main),
            // Other phases...
        };
        
        // Further implementation
    }
}
}

Commander Damage

Overview

Commander Damage is a fundamental aspect of the Commander format. Any player who has taken 21 or more combat damage from a single commander loses the game. This unique rule adds an additional win condition and strategic layer to the multiplayer format. The implementation must accurately track, accumulate, and verify commander damage while handling edge cases like commander ownership changes or damage redirection effects.

Random Effects and Commander Damage

Commander damage can be affected by various random effects, such as:

  • Coin flips that double damage or prevent damage
  • Dice rolls that modify combat damage
  • Random damage redirection

For test cases involving commander damage and random mechanics, see the Random Mechanics documentation.

Core Implementation

#![allow(unused)]
fn main() {
// Player component with commander damage tracking
#[derive(Component)]
pub struct Player {
    pub life_total: i32,
    pub commander_damage: HashMap<Entity, u32>,
    // Other player state fields omitted
}

// System to process commander damage
pub fn commander_damage_system(
    mut player_query: Query<(Entity, &mut Player)>,
    combat_system: Res<CombatSystem>,
    commanders: Query<Entity, With<Commander>>,
    mut game_events: EventWriter<GameEvent>,
) {
    // Collect any combat damage dealt by commanders
    let commander_damage_events = combat_system.combat_history
        .iter()
        .filter_map(|event| {
            if let CombatEvent::DamageDealt { source, target, amount, is_commander_damage } = event {
                if *is_commander_damage && commanders.contains(*source) {
                    return Some((*source, *target, *amount));
                }
            }
            None
        })
        .collect::<Vec<_>>();
    
    // Process the commander damage
    for (commander, target, amount) in commander_damage_events {
        for (player_entity, mut player) in player_query.iter_mut() {
            if player_entity == target {
                // Apply commander damage
                let previous_damage = player.commander_damage.get(&commander).copied().unwrap_or(0);
                let total_damage = previous_damage + amount;
                player.commander_damage.insert(commander, total_damage);
                
                // Check for commander damage loss condition
                if total_damage >= 21 {
                    game_events.send(GameEvent::PlayerLost {
                        player: player_entity,
                        reason: LossReason::CommanderDamage(commander),
                    });
                }
            }
        }
    }
}
}

Damage Application System

The combat damage system needs special handling for commander damage:

#![allow(unused)]
fn main() {
pub fn apply_combat_damage_system(
    mut combat_system: ResMut<CombatSystem>,
    turn_manager: Res<TurnManager>,
    mut creature_query: Query<(Entity, &mut Creature, Option<&Commander>)>,
    mut player_query: Query<(Entity, &mut Player)>,
    mut planeswalker_query: Query<(Entity, &mut Planeswalker)>,
    mut game_events: EventWriter<GameEvent>,
) {
    // Only run during combat damage step
    if turn_manager.current_phase != Phase::Combat(CombatStep::CombatDamage) {
        return;
    }
    
    // Process each attacker that wasn't blocked
    for (attacker, attack_data) in combat_system.attackers.iter() {
        // Skip attackers that were blocked
        if combat_system.blockers.values().any(|block_data| 
            block_data.blocked_attackers.contains(attacker)) {
            continue;
        }
        
        // Get attacker data
        if let Ok((_, creature, commander)) = creature_query.get(*attacker) {
            let power = creature.power;
            let is_commander = commander.is_some();
            
            // Apply damage to defender
            let defender = attack_data.defender;
            
            // Check if defender is a player
            if let Ok((player_entity, mut player)) = player_query.get_mut(defender) {
                // Apply damage to player
                player.life_total -= power as i32;
                
                // Record damage event
                combat_system.combat_history.push_back(CombatEvent::DamageDealt {
                    source: *attacker,
                    target: player_entity,
                    amount: power,
                    is_commander_damage: is_commander,
                });
                
                // Check if player lost due to life total
                if player.life_total <= 0 {
                    game_events.send(GameEvent::PlayerLost {
                        player: player_entity,
                        reason: LossReason::ZeroLife,
                    });
                }
            }
            // Check if defender is a planeswalker
            else if let Ok((planeswalker_entity, mut planeswalker)) = planeswalker_query.get_mut(defender) {
                // Apply damage to planeswalker
                planeswalker.loyalty -= power as i32;
                
                // Record damage event
                combat_system.combat_history.push_back(CombatEvent::DamageDealt {
                    source: *attacker,
                    target: planeswalker_entity,
                    amount: power,
                    is_commander_damage: false, // Commander damage only applies to players
                });
                
                // Check if planeswalker was destroyed
                if planeswalker.loyalty <= 0 {
                    game_events.send(GameEvent::PlaneswalkerDestroyed {
                        planeswalker: planeswalker_entity,
                    });
                }
            }
        }
    }
    
    // Similar processing for blocked attackers
    // Implementation details omitted for brevity
}
}

Handling Commander Identity Changes

When a commander changes identity (e.g., due to effects like Sakashima of a Thousand Faces), the damage tracking needs to be maintained:

#![allow(unused)]
fn main() {
pub fn handle_commander_identity_change(
    mut commands: Commands,
    mut combat_system: ResMut<CombatSystem>,
    mut player_query: Query<&mut Player>,
    commander_query: Query<(Entity, &Commander, &OriginalEntity)>,
) {
    for (current_entity, commander, original) in commander_query.iter() {
        // If the commander has a different original entity
        if current_entity != original.0 {
            // Update all players' commander damage tracking
            for mut player in player_query.iter_mut() {
                if let Some(damage) = player.commander_damage.remove(&original.0) {
                    player.commander_damage.insert(current_entity, damage);
                }
            }
            
            // Update combat system tracking if needed
            for attack_data in combat_system.attackers.values_mut() {
                if attack_data.attacker == original.0 {
                    attack_data.attacker = current_entity;
                    attack_data.is_commander = true;
                }
            }
        }
    }
}
}

UI Representation

The commander damage tracking needs to be visually represented to players:

#![allow(unused)]
fn main() {
pub fn update_commander_damage_ui_system(
    player_query: Query<(Entity, &Player)>,
    commander_query: Query<(Entity, &Commander, &Card)>,
    mut ui_state: ResMut<UiState>,
) {
    // Update UI representation of commander damage for each player
    for (player_entity, player) in player_query.iter() {
        let mut commander_damage_display = Vec::new();
        
        for (commander_entity, _, card) in commander_query.iter() {
            let damage = player.commander_damage.get(&commander_entity).copied().unwrap_or(0);
            
            commander_damage_display.push(CommanderDamageDisplay {
                commander_entity,
                commander_name: card.name.clone(),
                damage,
                progress: (damage as f32) / 21.0, // For progress bar visualization
                lethal: damage >= 21,
            });
        }
        
        // Sort by damage amount (highest first)
        commander_damage_display.sort_by(|a, b| b.damage.cmp(&a.damage));
        
        // Update UI state
        ui_state.player_commander_damage.insert(player_entity, commander_damage_display);
    }
}
}

Edge Cases

Damage Redirection

Some effects can redirect damage, which needs special handling for commander damage:

#![allow(unused)]
fn main() {
pub fn handle_damage_redirection(
    mut combat_system: ResMut<CombatSystem>,
    redirection_effects: Query<(Entity, &DamageRedirection)>,
) {
    // Check for redirection effects when processing damage events
    let mut redirected_damage = Vec::new();
    
    for event in combat_system.pending_combat_events.iter() {
        if let CombatEvent::DamageDealt { source, target, amount, is_commander_damage } = event {
            // Check for redirection effects affecting this target
            for (effect_entity, redirection) in redirection_effects.iter() {
                if redirection.target == *target {
                    // Redirect the damage
                    redirected_damage.push(CombatEvent::DamageDealt {
                        source: *source,
                        target: redirection.redirect_to,
                        amount: *amount,
                        // Important: Commander damage is preserved ONLY if damage source doesn't change
                        is_commander_damage: *is_commander_damage && redirection.preserve_source,
                    });
                }
            }
        }
    }
    
    // Add redirected damage events to combat history
    for event in redirected_damage {
        combat_system.combat_history.push_back(event);
    }
}
}

Change of Control

When a commander changes control, the damage tracking needs to be maintained:

#![allow(unused)]
fn main() {
pub fn handle_commander_control_change(
    mut player_query: Query<&mut Player>,
    mut control_change_events: EventReader<ControlChangeEvent>,
    commander_query: Query<Entity, With<Commander>>,
) {
    // Process control change events
    for event in control_change_events.iter() {
        // Only care about commanders changing control
        if commander_query.contains(event.entity) {
            // Commander damage is tracked by entity, so no changes needed to players' tracking
            // The entity's controller changes but damage tracking by entity ID remains the same
            
            // Log the event for record-keeping
            info!("Commander {:?} changed control from {:?} to {:?}", 
                  event.entity, event.old_controller, event.new_controller);
        }
    }
}
}

Double Strike and Commander Damage

Double strike creatures need special handling for commander damage:

#![allow(unused)]
fn main() {
pub fn handle_double_strike_commander_damage(
    mut combat_system: ResMut<CombatSystem>,
    creature_query: Query<(Entity, &Creature, Option<&Commander>)>,
) {
    // Get all double strike commanders
    let double_strike_commanders = creature_query.iter()
        .filter_map(|(entity, creature, commander)| {
            if commander.is_some() && creature.has_ability(Ability::Keyword(Keyword::DoubleStrike)) {
                Some(entity)
            } else {
                None
            }
        })
        .collect::<HashSet<_>>();
    
    // Special handling for double strike damage events
    let mut double_strike_events = Vec::new();
    
    for event in combat_system.combat_history.iter() {
        if let CombatEvent::DamageDealt { source, target, amount, is_commander_damage } = event {
            if *is_commander_damage && double_strike_commanders.contains(source) {
                // During first strike, we need to ensure this damage is tracked separately
                // from the regular damage that will come after
                if combat_system.active_combat_step == Some(CombatStep::FirstStrike) {
                    double_strike_events.push((*source, *target, *amount));
                }
            }
        }
    }
    
    // Record these events explicitly for commander damage tracking
    for (source, target, amount) in double_strike_events {
        info!("Tracking double strike first strike damage from commander {:?} to {:?}: {}", 
              source, target, amount);
    }
}
}

Testing Strategy

Unit Tests

#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
    use super::*;
    
    // Test basic commander damage tracking
    #[test]
    fn test_basic_commander_damage() {
        // Setup test environment
        let mut app = App::new();
        app.add_systems(Update, commander_damage_system);
        
        // Create test entities and components
        let commander = app.world.spawn((
            Card::default(),
            Commander,
            Creature { power: 5, toughness: 5, ..Default::default() },
        )).id();
        
        let player = app.world.spawn((
            Player {
                life_total: 40,
                commander_damage: HashMap::new(),
                ..Default::default()
            },
        )).id();
        
        // Create a combat system resource with damage history
        let mut combat_system = CombatSystem::default();
        combat_system.combat_history.push_back(CombatEvent::DamageDealt {
            source: commander,
            target: player,
            amount: 5,
            is_commander_damage: true,
        });
        app.insert_resource(combat_system);
        
        // Run the system
        app.update();
        
        // Verify commander damage was tracked
        let player_entity = app.world.entity(player);
        let player_component = player_entity.get::<Player>().unwrap();
        assert_eq!(player_component.commander_damage.get(&commander), Some(&5));
    }
    
    // Test commander damage loss condition
    #[test]
    fn test_commander_damage_loss() {
        // Setup test environment with 21 commander damage
        // Test code omitted for brevity
        
        // Run the system
        app.update();
        
        // Verify loss event was sent
        let events = app.world.resource::<Events<GameEvent>>();
        let mut event_reader = EventReader::<GameEvent>::from_resource(events);
        let mut found_loss_event = false;
        
        for event in event_reader.iter() {
            if let GameEvent::PlayerLost { player: p, reason: LossReason::CommanderDamage(c) } = event {
                if *p == player && *c == commander {
                    found_loss_event = true;
                    break;
                }
            }
        }
        
        assert!(found_loss_event, "Player loss event not found");
    }
    
    // Test commander damage from multiple commanders
    #[test]
    fn test_multiple_commander_damage() {
        // Setup test with multiple commanders dealing damage
        // Test code omitted for brevity
        
        // Verify each commander's damage is tracked separately
        let player_entity = app.world.entity(player);
        let player_component = player_entity.get::<Player>().unwrap();
        assert_eq!(player_component.commander_damage.get(&commander1), Some(&15));
        assert_eq!(player_component.commander_damage.get(&commander2), Some(&10));
        
        // Verify player hasn't lost yet (no single commander has dealt 21+ damage)
        let events = app.world.resource::<Events<GameEvent>>();
        let mut event_reader = EventReader::<GameEvent>::from_resource(events);
        
        for event in event_reader.iter() {
            if let GameEvent::PlayerLost { player: _, reason: _ } = event {
                panic!("Player should not have lost yet");
            }
        }
    }
}
}

Integration Tests

#![allow(unused)]
fn main() {
#[cfg(test)]
mod integration_tests {
    use super::*;
    
    // Test commander damage through complete combat
    #[test]
    fn test_commander_damage_through_combat() {
        // Setup full game environment
        let mut app = setup_test_game();
        
        // Setup a commander attacking a player
        let commander = /* create commander entity */;
        let player = /* create player entity */;
        
        // Setup the attack
        let mut combat_system = app.world.resource_mut::<CombatSystem>();
        combat_system.attackers.insert(commander, AttackData {
            attacker: commander,
            defender: player,
            is_commander: true,
            requirements: Vec::new(),
            restrictions: Vec::new(),
        });
        
        // Advance through combat damage step
        advance_to_combat_damage(&mut app);
        
        // Run the apply damage system
        app.update();
        
        // Verify commander damage was tracked
        let player_entity = app.world.entity(player);
        let player_component = player_entity.get::<Player>().unwrap();
        
        // Assuming a 5 power commander
        assert_eq!(player_component.commander_damage.get(&commander), Some(&5));
    }
    
    // Test commander damage accumulation over multiple turns
    #[test]
    fn test_commander_damage_accumulation() {
        // Setup game and play multiple turns with commander damage
        // Test code omitted for brevity
        
        // Verify accumulated commander damage
        let player_entity = app.world.entity(player);
        let player_component = player_entity.get::<Player>().unwrap();
        assert_eq!(player_component.commander_damage.get(&commander), Some(&15));
        
        // Deal more damage to hit 21
        // Test code omitted for brevity
        
        // Verify player lost
        let events = app.world.resource::<Events<GameEvent>>();
        let mut event_reader = EventReader::<GameEvent>::from_resource(events);
        let mut found_loss_event = false;
        
        for event in event_reader.iter() {
            if let GameEvent::PlayerLost { player: p, reason: LossReason::CommanderDamage(c) } = event {
                if *p == player && *c == commander {
                    found_loss_event = true;
                    break;
                }
            }
        }
        
        assert!(found_loss_event, "Player loss event not found");
    }
    
    // Test commander damage with protection/prevention
    #[test]
    fn test_commander_damage_prevention() {
        // Setup game with protection effects
        // Test code omitted for brevity
        
        // Verify commander damage was prevented
        let player_entity = app.world.entity(player);
        let player_component = player_entity.get::<Player>().unwrap();
        assert_eq!(player_component.commander_damage.get(&commander), Some(&0));
    }
}
}

Performance Considerations

  1. HashMap Efficiency: Commander damage is tracked using a HashMap which provides O(1) lookups, but care should be taken to avoid unnecessary cloning or rebuilding.

  2. Event Processing: Combat damage events are processed in batches to minimize system overhead.

  3. UI Updates: Commander damage UI updates should only happen when damage values change or during specific UI refresh cycles to avoid performance impact.

Implementation Recommendations

  1. Persistent Storage: In a networked game, commander damage should be persisted on the server and synchronized to clients.

  2. Undo Support: The system should support undoing or adjusting commander damage in case of rule disputes or corrections.

  3. History Tracking: Maintain a searchable history of commander damage events for game replay and verification.

  4. Visual Feedback: Provide clear visual feedback when commander damage is getting close to lethal levels (e.g., at 15+ damage).

Commander damage tracking is a critical component of the Commander format and requires careful implementation to ensure game rules are correctly enforced while maintaining good performance even in complex game states.

Multiplayer Combat

Overview

Commander is inherently a multiplayer format, which introduces unique complexities to the combat system. Unlike one-on-one matches, multiplayer combat allows a player to attack multiple opponents simultaneously and introduces political elements that affect combat decisions. This document details how the multiplayer aspects of combat are implemented, tested, and verified in our game engine.

Core Multiplayer Combat Features

Multiple Attack Targets

In Commander, the active player can declare attackers against different opponents in the same combat phase. This is implemented through the attack declaration system:

#![allow(unused)]
fn main() {
pub fn validate_multiplayer_attacks(
    combat_system: Res<CombatSystem>,
    turn_manager: Res<TurnManager>,
    player_query: Query<(Entity, &Player)>,
    mut game_events: EventWriter<GameEvent>,
) {
    let active_player = turn_manager.get_active_player();
    
    // Group attacks by defender
    let mut attacks_by_defender: HashMap<Entity, Vec<Entity>> = HashMap::new();
    
    for (attacker, attack_data) in &combat_system.attackers {
        attacks_by_defender.entry(attack_data.defender)
            .or_insert_with(Vec::new)
            .push(*attacker);
    }
    
    // Verify all defenders are opponents
    for (defender, attackers) in &attacks_by_defender {
        // Skip planeswalkers (they're handled separately)
        if !player_query.contains(*defender) {
            continue;
        }
        
        // Verify defender is not the active player
        if *defender == active_player {
            game_events.send(GameEvent::InvalidAttackTarget {
                attacker: attackers[0], // Just report one of the attackers
                defender: *defender,
                reason: "Cannot attack yourself".to_string(),
            });
        }
    }
    
    // Log all attack declarations for game history
    game_events.send(GameEvent::MultiplayerAttacksDeclared {
        active_player,
        attacks_by_defender: attacks_by_defender.clone(),
    });
}
}

Defending Player Choice

When a player is being attacked, they alone make blocking decisions for those attackers:

#![allow(unused)]
fn main() {
pub fn handle_multiplayer_blocks(
    mut combat_system: ResMut<CombatSystem>,
    turn_manager: Res<TurnManager>,
    mut block_events: EventReader<BlockDeclarationEvent>,
    player_query: Query<Entity, With<Player>>,
) {
    // Group attackers by defending player
    let mut attackers_by_defender: HashMap<Entity, Vec<Entity>> = HashMap::new();
    
    for (attacker, attack_data) in &combat_system.attackers {
        if player_query.contains(attack_data.defender) {
            attackers_by_defender.entry(attack_data.defender)
                .or_insert_with(Vec::new)
                .push(*attacker);
        }
    }
    
    // Process block declarations from each defending player
    for event in block_events.iter() {
        let BlockDeclarationEvent { player, blocker, attacker } = event;
        
        // Verify the player is declaring blocks against attackers targeting them
        if let Some(attackers) = attackers_by_defender.get(player) {
            if attackers.contains(attacker) {
                // This is a valid block declaration
                if let Some(block_data) = combat_system.blockers.get_mut(blocker) {
                    block_data.blocked_attackers.push(*attacker);
                } else {
                    combat_system.blockers.insert(*blocker, BlockData {
                        blocker: *blocker,
                        blocked_attackers: vec![*attacker],
                        requirements: Vec::new(),
                        restrictions: Vec::new(),
                    });
                }
            } else {
                // Player trying to block an attacker not targeting them
                warn!("Player {:?} tried to block attacker {:?} not targeting them", player, attacker);
            }
        }
    }
}
}

Political Game Elements

Goad Mechanic

Goad is a Commander-focused mechanic that forces creatures to attack players other than you. This is implemented as follows:

#![allow(unused)]
fn main() {
#[derive(Component)]
pub struct Goaded {
    pub source: Entity,
    pub until_end_of_turn: bool,
}

pub fn apply_goad_requirements(
    mut combat_system: ResMut<CombatSystem>,
    goaded_query: Query<(Entity, &Goaded, &Controllable)>,
    turn_manager: Res<TurnManager>,
) {
    let active_player = turn_manager.get_active_player();
    
    // Find all goaded creatures controlled by the active player
    for (entity, goaded, controllable) in goaded_query.iter() {
        if controllable.controller == active_player {
            // Add attack requirement - must attack if able
            combat_system.add_attack_requirement(entity, AttackRequirement::MustAttack);
            
            // Add attack restriction - can't attack the goad source
            combat_system.add_attack_restriction(entity, AttackRestriction::CantAttackPlayer(goaded.source));
        }
    }
}

pub fn cleanup_goad_effects(
    mut commands: Commands,
    goaded_query: Query<(Entity, &Goaded)>,
    turn_manager: Res<TurnManager>,
) {
    // Only run during end step
    if turn_manager.current_phase != Phase::Ending(EndingStep::End) {
        return;
    }
    
    // Remove goad effects that last until end of turn
    for (entity, goaded) in goaded_query.iter() {
        if goaded.until_end_of_turn {
            commands.entity(entity).remove::<Goaded>();
        }
    }
}
}

Monarch Mechanic

The monarch is another multiplayer-focused mechanic that encourages combat:

#![allow(unused)]
fn main() {
#[derive(Resource)]
pub struct Monarch(pub Option<Entity>);

pub fn monarch_attack_trigger(
    mut monarch: ResMut<Monarch>,
    combat_system: Res<CombatSystem>,
    turn_manager: Res<TurnManager>,
    mut game_events: EventWriter<GameEvent>,
) {
    // Only check at combat damage step
    if turn_manager.current_phase != Phase::Combat(CombatStep::CombatDamage) {
        return;
    }
    
    // Find players who were dealt combat damage
    let mut damaged_players = HashSet::new();
    
    for event in combat_system.combat_history.iter() {
        if let CombatEvent::DamageDealt { source, target, amount, .. } = event {
            if *amount > 0 && combat_system.attackers.contains_key(source) {
                damaged_players.insert(*target);
            }
        }
    }
    
    // Check if the monarch was dealt damage
    if let Some(current_monarch) = monarch.0 {
        if damaged_players.contains(&current_monarch) {
            // Find who dealt damage to the monarch
            for event in combat_system.combat_history.iter() {
                if let CombatEvent::DamageDealt { source, target, amount, .. } = event {
                    if *amount > 0 && *target == current_monarch && combat_system.attackers.contains_key(source) {
                        // Get the controller of the attacking creature
                        if let Some(controller) = get_controller(*source) {
                            if controller != current_monarch {
                                // Change the monarch
                                monarch.0 = Some(controller);
                                
                                game_events.send(GameEvent::MonarchChanged {
                                    old_monarch: current_monarch,
                                    new_monarch: controller,
                                    reason: "Combat damage".to_string(),
                                });
                                
                                // Only change once (first damage)
                                break;
                            }
                        }
                    }
                }
            }
        }
    }
}
}

Multiplayer Damage Tracking

In multiplayer games, damage source tracking becomes more important, especially for commander damage:

#![allow(unused)]
fn main() {
pub fn track_multiplayer_combat_damage(
    combat_system: Res<CombatSystem>,
    mut player_query: Query<(Entity, &mut Player)>,
    commander_query: Query<Entity, With<Commander>>,
    mut game_events: EventWriter<GameEvent>,
) {
    // Extract all combat damage events
    let damage_events = combat_system.combat_history
        .iter()
        .filter_map(|event| {
            if let CombatEvent::DamageDealt { source, target, amount, is_commander_damage } = event {
                Some((*source, *target, *amount, *is_commander_damage))
            } else {
                None
            }
        })
        .collect::<Vec<_>>();
    
    // Process commander damage
    for (source, target, amount, is_commander_damage) in damage_events {
        // Only process if the source is a commander and target is a player
        if is_commander_damage && commander_query.contains(source) {
            for (player_entity, mut player) in player_query.iter_mut() {
                if player_entity == target {
                    // Update commander damage tracking
                    let previous_damage = player.commander_damage.get(&source).copied().unwrap_or(0);
                    let new_damage = previous_damage + amount;
                    player.commander_damage.insert(source, new_damage);
                    
                    // Check for commander damage loss condition
                    if new_damage >= 21 {
                        game_events.send(GameEvent::PlayerLost {
                            player: player_entity,
                            reason: LossReason::CommanderDamage(source),
                        });
                    }
                }
            }
        }
    }
}
}

Teamwork Mechanics

Some Commander variants include team play, which requires special handling:

#![allow(unused)]
fn main() {
#[derive(Component)]
pub struct Team(pub u32);

pub fn validate_team_attacks(
    combat_system: Res<CombatSystem>,
    team_query: Query<(Entity, &Team)>,
    mut game_events: EventWriter<GameEvent>,
) {
    // Create team mappings
    let mut player_teams = HashMap::new();
    for (entity, team) in team_query.iter() {
        player_teams.insert(entity, team.0);
    }
    
    // Check for attacks against teammates
    for (attacker, attack_data) in &combat_system.attackers {
        if let Some(attacker_controller) = get_controller(*attacker) {
            if let (Some(attacker_team), Some(defender_team)) = (
                player_teams.get(&attacker_controller),
                player_teams.get(&attack_data.defender)
            ) {
                if attacker_team == defender_team {
                    // This is an attack against a teammate
                    game_events.send(GameEvent::TeamAttack {
                        attacker: *attacker,
                        defender: attack_data.defender,
                        team: *attacker_team,
                    });
                }
            }
        }
    }
}
}

Multiplayer Edge Cases

Player Elimination During Combat

If a player is eliminated during combat, any attackers targeting them need to be handled:

#![allow(unused)]
fn main() {
pub fn handle_player_elimination_during_combat(
    mut combat_system: ResMut<CombatSystem>,
    mut player_elimination_events: EventReader<PlayerEliminatedEvent>,
    mut commands: Commands,
) {
    for event in player_elimination_events.iter() {
        let eliminated_player = event.player;
        
        // Remove any attackers targeting the eliminated player
        let attackers_to_remove: Vec<Entity> = combat_system.attackers
            .iter()
            .filter_map(|(attacker, attack_data)| {
                if attack_data.defender == eliminated_player {
                    Some(*attacker)
                } else {
                    None
                }
            })
            .collect();
        
        for attacker in attackers_to_remove {
            combat_system.attackers.remove(&attacker);
            
            // Update creature component to no longer be attacking
            if let Some(mut creature) = commands.get_entity(attacker) {
                creature.insert(Creature {
                    attacking: None,
                    // Other fields preserved...
                    ..Default::default() // This would be replaced with actual preservation
                });
            }
        }
        
        // Remove any blockers controlled by the eliminated player
        let blockers_to_remove: Vec<Entity> = combat_system.blockers
            .iter()
            .filter_map(|(blocker, _)| {
                if get_controller(*blocker) == Some(eliminated_player) {
                    Some(*blocker)
                } else {
                    None
                }
            })
            .collect();
        
        for blocker in blockers_to_remove {
            combat_system.blockers.remove(&blocker);
            
            // Update creature component to no longer be blocking
            if let Some(mut creature) = commands.get_entity(blocker) {
                creature.insert(Creature {
                    blocking: Vec::new(),
                    // Other fields preserved...
                    ..Default::default() // This would be replaced with actual preservation
                });
            }
        }
    }
}
}

Redirect Attack Effects

Some cards can redirect attacks to different players:

#![allow(unused)]
fn main() {
pub fn handle_attack_redirection(
    mut combat_system: ResMut<CombatSystem>,
    redirection_effects: Query<(Entity, &AttackRedirection)>,
) {
    // Process any attack redirection effects
    let redirections: Vec<(Entity, Entity)> = combat_system.attackers
        .iter()
        .filter_map(|(attacker, attack_data)| {
            for (_, redirection) in redirection_effects.iter() {
                if redirection.original_defender == attack_data.defender 
                   && redirection.applies_to(*attacker) {
                    return Some((*attacker, redirection.new_defender));
                }
            }
            None
        })
        .collect();
    
    // Apply redirections
    for (attacker, new_defender) in redirections {
        if let Some(attack_data) = combat_system.attackers.get_mut(&attacker) {
            attack_data.defender = new_defender;
        }
    }
}

#[derive(Component)]
pub struct AttackRedirection {
    pub original_defender: Entity,
    pub new_defender: Entity,
    pub condition: RedirectionCondition,
}

impl AttackRedirection {
    pub fn applies_to(&self, attacker: Entity) -> bool {
        match &self.condition {
            RedirectionCondition::AllAttackers => true,
            RedirectionCondition::AttackerWithPower { operator, value } => {
                // Implementation details omitted
                true
            },
            // Other conditions...
            _ => false,
        }
    }
}
}

Testing Strategy

Multiplayer Combat Unit Tests

#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
    use super::*;
    
    #[test]
    fn test_multiple_attack_targets() {
        let mut app = App::new();
        app.add_systems(Update, validate_multiplayer_attacks);
        app.add_event::<GameEvent>();
        
        // Set up test environment
        let active_player = app.world.spawn(Player::default()).id();
        let opponent1 = app.world.spawn(Player::default()).id();
        let opponent2 = app.world.spawn(Player::default()).id();
        
        let attacker1 = app.world.spawn(Creature::default()).id();
        let attacker2 = app.world.spawn(Creature::default()).id();
        
        // Create turn manager
        let mut turn_manager = TurnManager::default();
        turn_manager.active_player_index = 0;
        turn_manager.player_order = vec![active_player, opponent1, opponent2];
        app.insert_resource(turn_manager);
        
        // Create combat system with attacks against multiple players
        let mut combat_system = CombatSystem::default();
        combat_system.attackers.insert(attacker1, AttackData {
            attacker: attacker1,
            defender: opponent1,
            is_commander: false,
            requirements: Vec::new(),
            restrictions: Vec::new(),
        });
        combat_system.attackers.insert(attacker2, AttackData {
            attacker: attacker2,
            defender: opponent2,
            is_commander: false,
            requirements: Vec::new(),
            restrictions: Vec::new(),
        });
        app.insert_resource(combat_system);
        
        // Run the system
        app.update();
        
        // Check for multiplay attack event
        let mut event_reader = app.world.resource_mut::<Events<GameEvent>>();
        let mut found_event = false;
        
        for event in event_reader.iter() {
            if let GameEvent::MultiplayerAttacksDeclared { .. } = event {
                found_event = true;
                break;
            }
        }
        
        assert!(found_event, "Multiplayer attack event not emitted");
    }
    
    #[test]
    fn test_goad_mechanic() {
        let mut app = App::new();
        app.add_systems(Update, apply_goad_requirements);
        
        // Set up test environment
        let active_player = app.world.spawn(Player::default()).id();
        let opponent1 = app.world.spawn(Player::default()).id();
        let opponent2 = app.world.spawn(Player::default()).id();
        
        // Create goaded creature
        let goaded_creature = app.world.spawn((
            Creature::default(),
            Controllable { controller: active_player },
            Goaded { source: opponent1, until_end_of_turn: true },
        )).id();
        
        // Create turn manager
        let mut turn_manager = TurnManager::default();
        turn_manager.active_player_index = 0;
        turn_manager.player_order = vec![active_player, opponent1, opponent2];
        app.insert_resource(turn_manager);
        
        // Create combat system
        let combat_system = CombatSystem::default();
        app.insert_resource(combat_system);
        
        // Run the system
        app.update();
        
        // Verify goad requirements were applied
        let combat_system = app.world.resource::<CombatSystem>();
        
        // Check that creature must attack
        let has_must_attack = combat_system.attack_requirements.get(&goaded_creature)
            .map_or(false, |reqs| reqs.iter().any(|req| matches!(req, AttackRequirement::MustAttack)));
        assert!(has_must_attack, "Goaded creature should have MustAttack requirement");
        
        // Check that creature can't attack goad source
        let has_cant_attack_source = combat_system.attack_restrictions.get(&goaded_creature)
            .map_or(false, |reqs| reqs.iter().any(|req| matches!(req, AttackRestriction::CantAttackPlayer(p) if *p == opponent1)));
        assert!(has_cant_attack_source, "Goaded creature should not be able to attack goad source");
    }
    
    #[test]
    fn test_monarch_mechanic() {
        let mut app = App::new();
        app.add_systems(Update, monarch_attack_trigger);
        app.add_event::<GameEvent>();
        
        // Set up test environment
        let player1 = app.world.spawn(Player::default()).id();
        let player2 = app.world.spawn(Player::default()).id();
        
        let attacker = app.world.spawn((
            Creature::default(),
            Controllable { controller: player2 },
        )).id();
        
        // Create turn manager with combat phase
        let mut turn_manager = TurnManager::default();
        turn_manager.current_phase = Phase::Combat(CombatStep::CombatDamage);
        app.insert_resource(turn_manager);
        
        // Set player1 as monarch
        app.insert_resource(Monarch(Some(player1)));
        
        // Create combat system with attack history
        let mut combat_system = CombatSystem::default();
        combat_system.attackers.insert(attacker, AttackData {
            attacker,
            defender: player1,
            is_commander: false,
            requirements: Vec::new(),
            restrictions: Vec::new(),
        });
        combat_system.combat_history.push_back(CombatEvent::DamageDealt {
            source: attacker,
            target: player1,
            amount: 3,
            is_commander_damage: false,
        });
        app.insert_resource(combat_system);
        
        // Run the system
        app.update();
        
        // Verify monarch changed
        let monarch = app.world.resource::<Monarch>();
        assert_eq!(monarch.0, Some(player2), "Monarch should have changed to player2");
    }
    
    // Additional tests...
}
}

Multiplayer Combat Integration Tests

#![allow(unused)]
fn main() {
#[cfg(test)]
mod integration_tests {
    use super::*;
    
    #[test]
    fn test_multiplayer_combat_sequence() {
        let mut builder = CombatScenarioBuilder::new();
        
        // Add multiple opponents
        let opponent1 = builder.add_player();
        let opponent2 = builder.add_player();
        
        // Add attackers for active player
        let attacker1 = builder.add_attacker(3, 3, builder.active_player, false);
        let attacker2 = builder.add_attacker(2, 2, builder.active_player, true); // Commander
        
        // Add blocker for first opponent
        let blocker = builder.add_blocker(2, 2, opponent1);
        
        // Declare attacks against multiple opponents
        builder.declare_attacks(vec![
            (attacker1, opponent1),
            (attacker2, opponent2),
        ]);
        
        // Declare blocks
        builder.declare_blocks(vec![
            (blocker, vec![attacker1]),
        ]);
        
        // Execute combat
        let result = builder.execute();
        
        // Verify results
        
        // Opponent1 should have lost no life (blocked) but blocker should be dead
        assert_eq!(result.player_life[&opponent1], 40);
        let blocker_status = result.creature_status.get(&blocker).unwrap();
        assert!(blocker_status.destroyed);
        
        // Opponent2 should have taken commander damage
        assert_eq!(result.player_life[&opponent2], 40 - 2);
        assert_eq!(result.commander_damage[&opponent2][&attacker2], 2);
    }
    
    #[test]
    fn test_player_elimination_during_combat() {
        let mut app = App::new();
        
        // Setup full game environment with multiple players
        // Implementation details omitted for brevity
        
        // Simulate combat damage that eliminates a player
        // Implementation details omitted for brevity
        
        // Verify that all attacks targeting the eliminated player are removed
        // Implementation details omitted for brevity
    }
    
    // Additional integration tests...
}
}

Multiplayer Combat System Tests

#![allow(unused)]
fn main() {
#[cfg(test)]
mod system_tests {
    use super::*;
    
    #[test]
    fn test_full_multiplayer_game() {
        let mut app = App::new();
        
        // Setup a 4-player Commander game
        let players = setup_four_player_game(&mut app);
        
        // Play through several turns with complex political interactions
        for _ in 0..10 {
            app.update();
            
            // Add various political actions between updates
            // (goad effects, attack redirection, monarch changes, etc.)
            // Implementation details omitted for brevity
        }
        
        // Verify multiplayer combat interactions worked as expected
        // Implementation details omitted for brevity
    }
    
    // Helper functions for system tests
    fn setup_four_player_game(app: &mut App) -> Vec<Entity> {
        // Implementation details omitted for brevity
        Vec::new()
    }
    
    // Additional system tests...
}
}

Performance Considerations

Multiplayer combat introduces additional computational complexity:

#![allow(unused)]
fn main() {
// Optimize attack target lookup
pub fn optimize_multiplayer_combat(
    mut combat_system: ResMut<CombatSystem>,
) {
    // Build attack target lookup table for faster validation
    let mut attack_target_map: HashMap<Entity, HashSet<Entity>> = HashMap::new();
    
    for (attacker, attack_data) in &combat_system.attackers {
        attack_target_map.entry(attack_data.defender)
            .or_insert_with(HashSet::new)
            .insert(*attacker);
    }
    
    combat_system.attack_target_map = attack_target_map;
}
}

UI Considerations

Multiplayer combat requires special attention to the user interface:

#![allow(unused)]
fn main() {
pub fn update_multiplayer_combat_ui(
    combat_system: Res<CombatSystem>,
    player_query: Query<(Entity, &Player)>,
    mut ui_state: ResMut<UiState>,
) {
    // Group attacks by defender for UI display
    let mut attacks_by_defender: HashMap<Entity, Vec<Entity>> = HashMap::new();
    
    for (attacker, attack_data) in &combat_system.attackers {
        attacks_by_defender.entry(attack_data.defender)
            .or_insert_with(Vec::new)
            .push(*attacker);
    }
    
    // Update UI state for each player
    for (player_entity, _) in player_query.iter() {
        // Display incoming attacks for this player
        if let Some(attackers) = attacks_by_defender.get(&player_entity) {
            ui_state.player_incoming_attacks.insert(player_entity, attackers.clone());
        } else {
            ui_state.player_incoming_attacks.insert(player_entity, Vec::new());
        }
    }
}
}

Networking Considerations

In a networked multiplayer game, commander-specific combat events need special handling:

#![allow(unused)]
fn main() {
pub fn replicate_multiplayer_combat_state(
    combat_system: Res<CombatSystem>,
    monarch: Res<Monarch>,
    mut replication: ResMut<Replication>,
) {
    // Replicate critical combat state
    replication.replicate_resource::<CombatSystem>();
    replication.replicate_resource::<Monarch>();
    
    // Replicate all combat-relevant components
    for (entity, _) in combat_system.attackers.iter() {
        replication.replicate_entity(*entity);
    }
    
    for (entity, _) in combat_system.blockers.iter() {
        replication.replicate_entity(*entity);
    }
}
}

Conclusion

Multiplayer combat in Commander adds significant complexity but also creates rich strategic gameplay. By properly implementing the systems described in this document, we ensure that players can fully experience the political dynamics and multiplayer interactions that make Commander such a beloved format. The implementation handles all the edge cases and unique mechanics while maintaining good performance even with four or more players.

Combat System

Overview

The Combat System is a critical component of the Commander game engine, handling all aspects of creature combat including attacks, blocks, damage assignment, and combat-specific abilities. This system is especially complex in Commander due to the multiplayer nature of the format and the special rules regarding commander damage.

Core Components

The combat system consists of several interconnected components:

  1. Combat Phase Management - Handling the flow through combat steps
  2. Attack Declaration System - Managing which creatures attack and who they attack
  3. Block Declaration System - Managing how defending players assign blockers
  4. Damage Assignment System - Calculating and applying combat damage
  5. Commander Damage Tracking - Monitoring and accumulating commander damage
  6. Combat Triggers - Handling abilities that trigger during combat

System Architecture

The combat system uses a modular design pattern to separate concerns:

#![allow(unused)]
fn main() {
// Core combat system resource
#[derive(Resource)]
pub struct CombatSystem {
    pub active_combat_step: Option<CombatStep>,
    pub attackers: HashMap<Entity, AttackData>,
    pub blockers: HashMap<Entity, BlockData>,
    pub combat_triggers: Vec<CombatTrigger>,
    pub damage_assignment_order: Vec<Entity>,
    pub combat_history: VecDeque<CombatEvent>,
}

// Data for an attacking creature
#[derive(Debug, Clone)]
pub struct AttackData {
    pub attacker: Entity,
    pub defender: Entity, // Can be a player or planeswalker
    pub is_commander: bool,
    pub requirements: Vec<AttackRequirement>,
    pub restrictions: Vec<AttackRestriction>,
}

// Data for a blocking creature
#[derive(Debug, Clone)]
pub struct BlockData {
    pub blocker: Entity,
    pub blocked_attackers: Vec<Entity>,
    pub requirements: Vec<BlockRequirement>,
    pub restrictions: Vec<BlockRestriction>,
}

// Combat event for history tracking
#[derive(Debug, Clone)]
pub enum CombatEvent {
    BeginCombat { turn: u32, active_player: Entity },
    AttackDeclared { attacker: Entity, defender: Entity },
    BlockDeclared { blocker: Entity, attacker: Entity },
    DamageDealt { source: Entity, target: Entity, amount: u32, is_commander_damage: bool },
    CombatEnded { turn: u32 },
}
}

Integration with Other Systems

The combat system interfaces with several other game systems:

  • Turn Manager - For phase control and player ordering
  • Stack System - For handling combat-triggered abilities
  • Damage System - For applying damage and handling prevention/redirection effects
  • Game State System - For tracking changes to the game state during combat
  • Player System - For player state changes (life totals, commander damage)

Documentation Structure

This documentation is organized into several parts:

Each section contains detailed information about implementation, edge cases, and testing strategies for that aspect of the combat system.

Combat Verification

Overview

A robust verification framework is critical for ensuring that the Combat System correctly implements the complex rules of Magic: The Gathering's Commander format. This document outlines our comprehensive testing approach, which covers unit testing, integration testing, system testing, and end-to-end testing strategies to verify both basic functionality and edge cases.

Testing Framework Architecture

#![allow(unused)]
fn main() {
// Test utility for setting up combat scenarios
pub struct CombatScenarioBuilder {
    pub app: App,
    pub active_player: Entity,
    pub players: Vec<Entity>,
    pub attackers: Vec<Entity>,
    pub blockers: Vec<Entity>,
    pub effects: Vec<(Entity, Effect)>,
}

impl CombatScenarioBuilder {
    pub fn new() -> Self {
        let mut app = App::new();
        
        // Add essential plugins and systems
        app.add_plugins(MinimalPlugins)
           .add_plugins(TestTurnSystemPlugin)
           .add_plugins(TestCombatSystemPlugin);
        
        // Initialize resources
        app.insert_resource(CombatSystem::default());
        app.insert_resource(TurnManager::default());
        
        // Create default active player
        let active_player = app.world.spawn(Player {
            life_total: 40,
            commander_damage: HashMap::new(),
            ..Default::default()
        }).id();
        
        Self {
            app,
            active_player,
            players: vec![active_player],
            attackers: Vec::new(),
            blockers: Vec::new(),
            effects: Vec::new(),
        }
    }
    
    // Add a player to the scenario
    pub fn add_player(&mut self) -> Entity {
        let player = self.app.world.spawn(Player {
            life_total: 40,
            commander_damage: HashMap::new(),
            ..Default::default()
        }).id();
        
        self.players.push(player);
        player
    }
    
    // Add an attacking creature
    pub fn add_attacker(&mut self, 
                        power: u32, 
                        toughness: u32, 
                        controller: Entity, 
                        is_commander: bool) -> Entity {
        let mut components = (
            Card::default(),
            Creature {
                power,
                toughness,
                attacking: None,
                blocking: Vec::new(),
                ..Default::default()
            },
            Controllable { controller },
        );
        
        let entity = if is_commander {
            self.app.world.spawn((components, Commander)).id()
        } else {
            self.app.world.spawn(components).id()
        };
        
        self.attackers.push(entity);
        entity
    }
    
    // Add a blocking creature
    pub fn add_blocker(&mut self, 
                      power: u32, 
                      toughness: u32, 
                      controller: Entity) -> Entity {
        let entity = self.app.world.spawn((
            Card::default(),
            Creature {
                power,
                toughness,
                attacking: None,
                blocking: Vec::new(),
                ..Default::default()
            },
            Controllable { controller },
        )).id();
        
        self.blockers.push(entity);
        entity
    }
    
    // Add an effect to an entity
    pub fn add_effect(&mut self, entity: Entity, effect: Effect) {
        self.effects.push((entity, effect));
        
        // Apply the effect to the entity
        let mut effects = self.app.world.entity_mut(entity)
            .get_mut::<ActiveEffects>()
            .unwrap_or_else(|| {
                self.app.world.entity_mut(entity).insert(ActiveEffects(Vec::new()));
                self.app.world.entity_mut(entity).get_mut::<ActiveEffects>().unwrap()
            });
        
        effects.0.push(effect);
    }
    
    // Set up attacks
    pub fn declare_attacks(&mut self, attacks: Vec<(Entity, Entity)>) {
        let mut combat_system = self.app.world.resource_mut::<CombatSystem>();
        
        for (attacker, defender) in attacks {
            // Update creature component
            let mut attacker_entity = self.app.world.entity_mut(attacker);
            let mut creature = attacker_entity.get_mut::<Creature>().unwrap();
            creature.attacking = Some(defender);
            
            // Check if attacker is a commander
            let is_commander = attacker_entity.contains::<Commander>();
            
            // Add to combat system
            combat_system.attackers.insert(attacker, AttackData {
                attacker,
                defender,
                is_commander,
                requirements: Vec::new(),
                restrictions: Vec::new(),
            });
        }
    }
    
    // Set up blocks
    pub fn declare_blocks(&mut self, blocks: Vec<(Entity, Vec<Entity>)>) {
        let mut combat_system = self.app.world.resource_mut::<CombatSystem>();
        
        for (blocker, blocked_attackers) in blocks {
            // Update creature component
            let mut blocker_entity = self.app.world.entity_mut(blocker);
            let mut creature = blocker_entity.get_mut::<Creature>().unwrap();
            creature.blocking = blocked_attackers.clone();
            
            // Add to combat system
            combat_system.blockers.insert(blocker, BlockData {
                blocker,
                blocked_attackers,
                requirements: Vec::new(),
                restrictions: Vec::new(),
            });
        }
    }
    
    // Advance to a specific combat step
    pub fn advance_to(&mut self, step: CombatStep) {
        let mut turn_manager = self.app.world.resource_mut::<TurnManager>();
        turn_manager.current_phase = Phase::Combat(step);
        
        let mut combat_system = self.app.world.resource_mut::<CombatSystem>();
        combat_system.active_combat_step = Some(step);
    }
    
    // Execute combat and return results
    pub fn execute(&mut self) -> CombatResult {
        // Run the relevant systems based on the current step
        match self.app.world.resource::<CombatSystem>().active_combat_step {
            Some(CombatStep::Beginning) => {
                self.app.update_with_system(beginning_of_combat_system);
            }
            Some(CombatStep::DeclareAttackers) => {
                self.app.update_with_system(declare_attackers_system);
            }
            Some(CombatStep::DeclareBlockers) => {
                self.app.update_with_system(declare_blockers_system);
            }
            Some(CombatStep::FirstStrike) => {
                self.app.update_with_system(first_strike_damage_system);
            }
            Some(CombatStep::CombatDamage) => {
                self.app.update_with_system(apply_combat_damage_system);
            }
            Some(CombatStep::End) => {
                self.app.update_with_system(end_of_combat_system);
            }
            None => {
                // Run all systems in sequence
                self.advance_to(CombatStep::Beginning);
                self.app.update_with_system(beginning_of_combat_system);
                
                self.advance_to(CombatStep::DeclareAttackers);
                self.app.update_with_system(declare_attackers_system);
                
                self.advance_to(CombatStep::DeclareBlockers);
                self.app.update_with_system(declare_blockers_system);
                
                // Check if first strike is needed
                if self.has_first_strike_creatures() {
                    self.advance_to(CombatStep::FirstStrike);
                    self.app.update_with_system(first_strike_damage_system);
                }
                
                self.advance_to(CombatStep::CombatDamage);
                self.app.update_with_system(apply_combat_damage_system);
                
                self.advance_to(CombatStep::End);
                self.app.update_with_system(end_of_combat_system);
            }
        }
        
        // Collect and return results
        self.collect_combat_results()
    }
    
    // Helper to check if any creatures have first strike
    fn has_first_strike_creatures(&self) -> bool {
        for attacker in &self.attackers {
            if let Ok(creature) = self.app.world.entity(*attacker).get::<Creature>() {
                if creature.has_ability(Ability::Keyword(Keyword::FirstStrike)) || 
                   creature.has_ability(Ability::Keyword(Keyword::DoubleStrike)) {
                    return true;
                }
            }
        }
        
        for blocker in &self.blockers {
            if let Ok(creature) = self.app.world.entity(*blocker).get::<Creature>() {
                if creature.has_ability(Ability::Keyword(Keyword::FirstStrike)) || 
                   creature.has_ability(Ability::Keyword(Keyword::DoubleStrike)) {
                    return true;
                }
            }
        }
        
        false
    }
    
    // Collect results of combat
    fn collect_combat_results(&self) -> CombatResult {
        let mut result = CombatResult::default();
        
        // Collect player life totals and commander damage
        for player in &self.players {
            if let Ok(player_component) = self.app.world.entity(*player).get::<Player>() {
                result.player_life.insert(*player, player_component.life_total);
                result.commander_damage.insert(*player, player_component.commander_damage.clone());
            }
        }
        
        // Collect creature status
        for creature in self.attackers.iter().chain(self.blockers.iter()) {
            if let Ok(creature_component) = self.app.world.entity(*creature).get::<Creature>() {
                result.creature_status.insert(*creature, CreatureStatus {
                    power: creature_component.power,
                    toughness: creature_component.toughness,
                    damage: creature_component.damage,
                    destroyed: self.app.world.entity(*creature).contains::<Destroyed>(),
                    tapped: self.app.world.entity(*creature).contains::<Tapped>(),
                });
            }
        }
        
        // Collect combat events
        let combat_system = self.app.world.resource::<CombatSystem>();
        result.combat_events = combat_system.combat_history.clone();
        
        result
    }
}

// Structure to hold combat test results
#[derive(Default)]
pub struct CombatResult {
    pub player_life: HashMap<Entity, i32>,
    pub commander_damage: HashMap<Entity, HashMap<Entity, u32>>,
    pub creature_status: HashMap<Entity, CreatureStatus>,
    pub combat_events: VecDeque<CombatEvent>,
}

#[derive(Default)]
pub struct CreatureStatus {
    pub power: u32,
    pub toughness: u32,
    pub damage: u32,
    pub destroyed: bool,
    pub tapped: bool,
}
}

Unit Testing

Combat Steps Tests

Each combat step has dedicated unit tests to verify its functionality in isolation:

#![allow(unused)]
fn main() {
#[cfg(test)]
mod beginning_of_combat_tests {
    use super::*;
    
    #[test]
    fn test_beginning_of_combat_initialization() {
        let mut builder = CombatScenarioBuilder::new();
        
        // Add some creatures
        let opponent = builder.add_player();
        builder.add_attacker(2, 2, builder.active_player, false);
        builder.add_attacker(3, 3, builder.active_player, true); // Commander
        
        // Set up the beginning of combat step
        builder.advance_to(CombatStep::Beginning);
        
        // Execute and get results
        let result = builder.execute();
        
        // Verify combat system is properly initialized
        let combat_system = builder.app.world.resource::<CombatSystem>();
        assert_eq!(combat_system.active_combat_step, Some(CombatStep::Beginning));
        
        // Verify begin combat event was recorded
        assert!(result.combat_events.iter().any(|event| 
            matches!(event, CombatEvent::BeginCombat { .. })));
    }
    
    // Additional tests...
}

#[cfg(test)]
mod declare_attackers_tests {
    use super::*;
    
    #[test]
    fn test_declare_attackers_basic() {
        let mut builder = CombatScenarioBuilder::new();
        
        // Add some creatures and a player to attack
        let opponent = builder.add_player();
        let attacker1 = builder.add_attacker(2, 2, builder.active_player, false);
        let attacker2 = builder.add_attacker(3, 3, builder.active_player, true); // Commander
        
        // Set up attacks
        builder.advance_to(CombatStep::DeclareAttackers);
        builder.declare_attacks(vec![
            (attacker1, opponent),
            (attacker2, opponent),
        ]);
        
        // Execute and get results
        let result = builder.execute();
        
        // Verify creatures are marked as attacking
        let combat_system = builder.app.world.resource::<CombatSystem>();
        assert_eq!(combat_system.attackers.len(), 2);
        
        // Verify attack events were recorded
        assert_eq!(
            result.combat_events.iter()
                .filter(|event| matches!(event, CombatEvent::AttackDeclared { .. }))
                .count(),
            2
        );
        
        // Verify creatures are tapped
        for (entity, status) in &result.creature_status {
            assert!(status.tapped);
        }
    }
    
    // Additional tests...
}

// Similar unit tests for other combat steps...
}

Edge Case Tests

Dedicated tests for all identified edge cases:

#![allow(unused)]
fn main() {
#[cfg(test)]
mod edge_case_tests {
    use super::*;
    
    #[test]
    fn test_indestructible_creatures() {
        let mut builder = CombatScenarioBuilder::new();
        
        // Setup creatures
        let opponent = builder.add_player();
        let attacker = builder.add_attacker(2, 2, builder.active_player, false);
        let blocker = builder.add_blocker(4, 4, opponent);
        
        // Make attacker indestructible
        builder.add_effect(attacker, Effect::Indestructible);
        
        // Set up combat
        builder.advance_to(CombatStep::DeclareAttackers);
        builder.declare_attacks(vec![(attacker, opponent)]);
        
        builder.advance_to(CombatStep::DeclareBlockers);
        builder.declare_blocks(vec![(blocker, vec![attacker])]);
        
        // Execute full combat
        builder.advance_to(CombatStep::CombatDamage);
        let result = builder.execute();
        
        // Verify attacker received lethal damage but wasn't destroyed
        let attacker_status = result.creature_status.get(&attacker).unwrap();
        assert_eq!(attacker_status.damage, 4); // Received damage
        assert!(!attacker_status.destroyed); // But not destroyed
        
        // Verify blocker took damage
        let blocker_status = result.creature_status.get(&blocker).unwrap();
        assert_eq!(blocker_status.damage, 2);
        assert!(!blocker_status.destroyed);
    }
    
    #[test]
    fn test_deathtouch() {
        let mut builder = CombatScenarioBuilder::new();
        
        // Setup creatures
        let opponent = builder.add_player();
        let attacker = builder.add_attacker(1, 1, builder.active_player, false);
        let blocker = builder.add_blocker(10, 10, opponent);
        
        // Add deathtouch to attacker
        builder.add_effect(attacker, Effect::Keyword(Keyword::Deathtouch));
        
        // Set up combat
        builder.advance_to(CombatStep::DeclareAttackers);
        builder.declare_attacks(vec![(attacker, opponent)]);
        
        builder.advance_to(CombatStep::DeclareBlockers);
        builder.declare_blocks(vec![(blocker, vec![attacker])]);
        
        // Execute full combat
        builder.advance_to(CombatStep::CombatDamage);
        let result = builder.execute();
        
        // Verify blocker was destroyed by deathtouch
        let blocker_status = result.creature_status.get(&blocker).unwrap();
        assert!(blocker_status.destroyed);
        
        // Verify attacker was also destroyed 
        let attacker_status = result.creature_status.get(&attacker).unwrap();
        assert!(attacker_status.destroyed);
    }
    
    #[test]
    fn test_trample() {
        let mut builder = CombatScenarioBuilder::new();
        
        // Setup creatures
        let opponent = builder.add_player();
        let attacker = builder.add_attacker(6, 6, builder.active_player, false);
        let blocker = builder.add_blocker(2, 2, opponent);
        
        // Add trample to attacker
        builder.add_effect(attacker, Effect::Keyword(Keyword::Trample));
        
        // Set up combat
        builder.advance_to(CombatStep::DeclareAttackers);
        builder.declare_attacks(vec![(attacker, opponent)]);
        
        builder.advance_to(CombatStep::DeclareBlockers);
        builder.declare_blocks(vec![(blocker, vec![attacker])]);
        
        // Execute full combat
        builder.advance_to(CombatStep::CombatDamage);
        let result = builder.execute();
        
        // Verify opponent took trample damage
        assert_eq!(result.player_life[&opponent], 40 - 4); // 6 power - 2 toughness = 4 damage
        
        // Verify blocker was destroyed
        let blocker_status = result.creature_status.get(&blocker).unwrap();
        assert!(blocker_status.destroyed);
    }
    
    #[test]
    fn test_multiple_blockers() {
        let mut builder = CombatScenarioBuilder::new();
        
        // Setup creatures
        let opponent = builder.add_player();
        let attacker = builder.add_attacker(5, 5, builder.active_player, false);
        let blocker1 = builder.add_blocker(2, 2, opponent);
        let blocker2 = builder.add_blocker(2, 2, opponent);
        
        // Set up combat
        builder.advance_to(CombatStep::DeclareAttackers);
        builder.declare_attacks(vec![(attacker, opponent)]);
        
        builder.advance_to(CombatStep::DeclareBlockers);
        builder.declare_blocks(vec![
            (blocker1, vec![attacker]),
            (blocker2, vec![attacker]),
        ]);
        
        // Add damage assignment order
        let mut combat_system = builder.app.world.resource_mut::<CombatSystem>();
        combat_system.damage_assignment_order = vec![blocker1, blocker2];
        
        // Execute full combat
        builder.advance_to(CombatStep::CombatDamage);
        let result = builder.execute();
        
        // Verify both blockers were destroyed
        let blocker1_status = result.creature_status.get(&blocker1).unwrap();
        assert!(blocker1_status.destroyed);
        
        let blocker2_status = result.creature_status.get(&blocker2).unwrap();
        assert!(blocker2_status.destroyed);
        
        // Verify attacker took damage from both blockers
        let attacker_status = result.creature_status.get(&attacker).unwrap();
        assert_eq!(attacker_status.damage, 4);
        assert!(!attacker_status.destroyed);
        
        // Verify player took no damage
        assert_eq!(result.player_life[&opponent], 40);
    }
    
    // Many more edge case tests...
}
}

Integration Tests

Integration tests verify that different parts of the combat system work together correctly:

#![allow(unused)]
fn main() {
#[cfg(test)]
mod integration_tests {
    use super::*;
    
    #[test]
    fn test_full_combat_sequence() {
        let mut builder = CombatScenarioBuilder::new();
        
        // Setup a complex combat scenario
        let opponent = builder.add_player();
        let attacker1 = builder.add_attacker(3, 3, builder.active_player, false);
        let attacker2 = builder.add_attacker(4, 4, builder.active_player, true); // Commander
        let blocker1 = builder.add_blocker(2, 2, opponent);
        let blocker2 = builder.add_blocker(5, 5, opponent);
        
        // Add some abilities
        builder.add_effect(attacker1, Effect::Keyword(Keyword::Trample));
        builder.add_effect(blocker2, Effect::Keyword(Keyword::Deathtouch));
        
        // Run through the entire combat sequence
        builder.declare_attacks(vec![
            (attacker1, opponent),
            (attacker2, opponent),
        ]);
        
        builder.declare_blocks(vec![
            (blocker1, vec![attacker1]),
            (blocker2, vec![attacker2]),
        ]);
        
        // Execute and get results
        let result = builder.execute();
        
        // Verify final state
        
        // Attacker1 should have dealt trample damage
        assert_eq!(result.player_life[&opponent], 40 - 1); // 3 power - 2 toughness = 1 damage
        
        // Attacker2 (commander) should be destroyed by deathtouch
        let attacker2_status = result.creature_status.get(&attacker2).unwrap();
        assert!(attacker2_status.destroyed);
        
        // Blocker1 should be destroyed
        let blocker1_status = result.creature_status.get(&blocker1).unwrap();
        assert!(blocker1_status.destroyed);
        
        // Blocker2 should take damage but survive
        let blocker2_status = result.creature_status.get(&blocker2).unwrap();
        assert_eq!(blocker2_status.damage, 4);
        assert!(!blocker2_status.destroyed);
        
        // Commander damage should be tracked
        assert_eq!(result.commander_damage[&opponent][&attacker2], 4);
    }
    
    #[test]
    fn test_complex_combat_with_triggers() {
        // Test implementation omitted for brevity
        // This would test combat with abilities that trigger on attack, block, or damage
    }
    
    #[test]
    fn test_multiple_turns_commander_damage() {
        // Test that tracks commander damage across multiple turns
        // Implementation omitted for brevity
    }
    
    // Additional integration tests...
}
}

System Tests

System tests verify the combat system's interaction with other systems:

#![allow(unused)]
fn main() {
#[cfg(test)]
mod system_tests {
    use super::*;
    
    #[test]
    fn test_combat_with_stack_interaction() {
        let mut app = App::new();
        
        // Add complete game systems
        app.add_plugins(MinimalPlugins)
           .add_plugins(GameLogicPlugins);
        
        // Set up test entities and start a game
        // Implementation omitted for brevity
        
        // Create a combat scenario with abilities that go on the stack
        // Implementation omitted for brevity
        
        // Run the game until combat is complete
        for _ in 0..20 { // Limit iterations to prevent infinite loops
            app.update();
            
            // Check if combat is complete
            let combat_system = app.world.resource::<CombatSystem>();
            if combat_system.active_combat_step == Some(CombatStep::End) {
                break;
            }
        }
        
        // Verify stack interactions worked correctly
        // Implementation omitted for brevity
    }
    
    #[test]
    fn test_combat_with_state_changes() {
        // Test that combat properly interacts with game state changes
        // Implementation omitted for brevity
    }
    
    // Additional system tests...
}
}

End-to-End Tests

End-to-end tests verify the combat system in the context of a complete game:

#![allow(unused)]
fn main() {
#[cfg(test)]
mod e2e_tests {
    use super::*;
    
    #[test]
    fn test_complete_game_with_combat() {
        let mut app = App::new();
        
        // Add complete game plugins
        app.add_plugins(DefaultPlugins)
           .add_plugins(GamePlugins);
        
        // Set up a complete game with multiple players
        setup_complete_game(&mut app, 4); // 4 players
        
        // Play through several turns
        for _ in 0..10 { // 10 turns
            play_turn(&mut app);
        }
        
        // Verify game state
        let game_state = app.world.resource::<GameState>();
        
        // Check remaining players
        let player_query = app.world.query_filtered::<Entity, With<Player>>();
        let remaining_players = player_query.iter(&app.world).count();
        
        // In most cases, we should still have multiple players
        assert!(remaining_players > 1, "Game ended too quickly");
        
        // Verify commander damage has been dealt
        let player_query = app.world.query::<&Player>();
        let has_commander_damage = player_query
            .iter(&app.world)
            .any(|player| !player.commander_damage.is_empty());
        
        assert!(has_commander_damage, "No commander damage was dealt in the game");
    }
    
    #[test]
    fn test_commander_damage_victory() {
        // Test a game where a player wins via commander damage
        // Implementation omitted for brevity
    }
    
    // Helper function to play a single turn
    fn play_turn(app: &mut App) {
        // Get current phase
        let turn_manager = app.world.resource::<TurnManager>();
        let start_phase = turn_manager.current_phase;
        
        // Run updates until we complete a turn
        for _ in 0..100 { // Limit iterations to prevent infinite loops
            app.update();
            
            let turn_manager = app.world.resource::<TurnManager>();
            
            // If we've come back to the same phase, we've completed a turn
            if turn_manager.current_phase == start_phase 
               && turn_manager.active_player_index != turn_manager.active_player_index {
                break;
            }
        }
    }
    
    // Helper function to set up a complete game
    fn setup_complete_game(app: &mut App, num_players: usize) {
        // Implementation omitted for brevity
    }
    
    // Additional end-to-end tests...
}
}

Property-Based Testing

We also employ property-based testing to verify invariants across randomized scenarios:

#![allow(unused)]
fn main() {
#[cfg(test)]
mod property_tests {
    use super::*;
    use proptest::prelude::*;
    
    proptest! {
        #[test]
        fn commander_damage_consistency(
            // Generate random commander power between 1 and 10
            commander_power in 1u32..10,
            // Generate random number of combat rounds between 1 and 5
            combat_rounds in 1usize..5
        ) {
            // Setup combat scenario with commander of specified power
            let mut builder = CombatScenarioBuilder::new();
            let opponent = builder.add_player();
            let commander = builder.add_attacker(commander_power, commander_power, builder.active_player, true);
            
            // Track expected damage
            let mut expected_damage = 0;
            
            // Simulate multiple rounds of combat
            for _ in 0..combat_rounds {
                builder.advance_to(CombatStep::DeclareAttackers);
                builder.declare_attacks(vec![(commander, opponent)]);
                
                // No blocks for simplicity
                builder.advance_to(CombatStep::DeclareBlockers);
                
                // Execute combat damage
                builder.advance_to(CombatStep::CombatDamage);
                builder.execute();
                
                // Update expected damage
                expected_damage += commander_power;
            }
            
            // Check final state
            let result = builder.collect_combat_results();
            
            // Verify commander damage matches expected total
            prop_assert_eq!(
                result.commander_damage[&opponent][&commander],
                expected_damage
            );
            
            // Verify life total reflects damage
            prop_assert_eq!(
                result.player_life[&opponent],
                40 - (expected_damage as i32)
            );
        }
        
        #[test]
        fn multiple_attackers_damage_distribution(
            // Generate 1-5 attackers with power 1-5 each
            attackers in prop::collection::vec((1u32..5, 1u32..5), 1..5)
        ) {
            let mut builder = CombatScenarioBuilder::new();
            let opponent = builder.add_player();
            
            // Create attackers and track total power
            let mut attacker_entities = Vec::new();
            let mut total_power = 0;
            
            for (power, toughness) in attackers {
                let attacker = builder.add_attacker(
                    power, toughness, builder.active_player, false);
                attacker_entities.push(attacker);
                total_power += power;
            }
            
            // Declare all attackers attacking opponent
            builder.advance_to(CombatStep::DeclareAttackers);
            builder.declare_attacks(
                attacker_entities.iter()
                    .map(|&a| (a, opponent))
                    .collect()
            );
            
            // No blocks
            builder.advance_to(CombatStep::DeclareBlockers);
            
            // Execute combat damage
            builder.advance_to(CombatStep::CombatDamage);
            let result = builder.execute();
            
            // Verify opponent's life total correctly reflects all damage
            prop_assert_eq!(
                result.player_life[&opponent],
                40 - (total_power as i32)
            );
        }
    }
}
}

Test Coverage Goals

Our testing strategy aims for the following coverage metrics:

  1. Line Coverage: At least 90% of all combat-related code
  2. Branch Coverage: At least 85% of all conditional branches
  3. Path Coverage: At least 75% of all execution paths
  4. Edge Case Coverage: 100% of identified edge cases have dedicated tests

Continuous Integration

All combat tests are integrated into the CI pipeline with the following workflow:

  1. Unit tests run on every commit
  2. Integration tests run on every PR
  3. System and end-to-end tests run nightly
  4. Coverage reports generated after each test run

Debugging Tools

To assist with debugging, we provide specialized tools:

#![allow(unused)]
fn main() {
/// Utility for debugging combat scenarios
pub fn debug_combat_state(combat_system: &CombatSystem, world: &World) {
    println!("=== COMBAT DEBUG STATE ===");
    
    // Print current combat step
    println!("Combat Step: {:?}", combat_system.active_combat_step);
    
    // Print attackers
    println!("\nAttackers:");
    for (entity, attack_data) in &combat_system.attackers {
        if let Ok((_, creature, _)) = world.query::<(Entity, &Creature, Option<&Commander>)>().get(world, *entity) {
            println!("  {:?} ({}:{}) attacking {:?}{}",
                entity,
                creature.power,
                creature.toughness,
                attack_data.defender,
                if attack_data.is_commander { " [COMMANDER]" } else { "" }
            );
        }
    }
    
    // Print blockers
    println!("\nBlockers:");
    for (entity, block_data) in &combat_system.blockers {
        if let Ok((_, creature)) = world.query::<(Entity, &Creature)>().get(world, *entity) {
            println!("  {:?} ({}:{}) blocking: {:?}",
                entity,
                creature.power,
                creature.toughness,
                block_data.blocked_attackers
            );
        }
    }
    
    // Print combat history
    println!("\nCombat History:");
    for (i, event) in combat_system.combat_history.iter().enumerate() {
        println!("  [{}] {:?}", i, event);
    }
    
    println!("=========================");
}
}

Verification Strategy Evolution

Our verification approach is not static; it evolves over time:

  1. New edge cases identified during testing or gameplay are added to the test suite
  2. Performance bottlenecks identified through testing are addressed
  3. Test frameworks are updated as the combat system evolves
  4. Regression tests are added for any bugs found in production

By following this comprehensive verification strategy, we ensure the Commander combat system correctly implements all rules, handles edge cases properly, and provides a robust foundation for the game engine.

Beginning of Combat Step

Overview

The Beginning of Combat step is the first step of the Combat Phase in Magic: The Gathering. This step serves as a transition between the pre-combat Main Phase and the declaration of attackers. It provides a crucial window for players to cast instants and activate abilities before attackers are declared. This document details the implementation of the Beginning of Combat step in our Commander game engine.

Core Implementation

Phase Structure

The Beginning of Combat step is implemented as part of the overall combat phase system:

#![allow(unused)]
fn main() {
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CombatStep {
    BeginningOfCombat,
    DeclareAttackers,
    DeclareBlockers,
    CombatDamage,
    EndOfCombat,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Phase {
    // Other phases...
    Combat(CombatStep),
    // Other phases...
}
}

Beginning of Combat System

The core system that handles the Beginning of Combat step:

#![allow(unused)]
fn main() {
pub fn beginning_of_combat_system(
    mut commands: Commands,
    turn_manager: Res<TurnManager>,
    mut game_events: EventWriter<GameEvent>,
    active_player: Query<Entity, With<ActivePlayer>>,
    mut next_phase: ResMut<NextState<Phase>>,
    mut priority_system: ResMut<PrioritySystem>,
) {
    // Only run during Beginning of Combat
    if turn_manager.current_phase != Phase::Combat(CombatStep::BeginningOfCombat) {
        return;
    }

    // If this is the first time entering the step
    if !priority_system.priority_given {
        // Emit Beginning of Combat event
        let active_player_entity = active_player.single();
        game_events.send(GameEvent::BeginningOfCombat {
            player: active_player_entity,
        });
        
        // Clear any "until end of combat" effects from previous turns
        commands.run_system(clear_end_of_combat_effects);
        
        // Initialize any tracked combat data
        commands.insert_resource(CombatData::default());
        
        // Grant priority to active player
        priority_system.grant_initial_priority();
    }
    
    // If all players have passed priority without adding to the stack
    if priority_system.all_players_passed() && !priority_system.stack_changed {
        // Proceed to Declare Attackers step
        next_phase.set(Phase::Combat(CombatStep::DeclareAttackers));
        priority_system.priority_given = false;
    }
}
}

Event Triggers

The beginning of combat step triggers various abilities:

#![allow(unused)]
fn main() {
pub fn beginning_of_combat_triggers(
    turn_manager: Res<TurnManager>,
    mut ability_triggers: ResMut<AbilityTriggerQueue>,
    trigger_sources: Query<(Entity, &AbilityTrigger)>,
) {
    // Only run during Beginning of Combat
    if turn_manager.current_phase != Phase::Combat(CombatStep::BeginningOfCombat) {
        return;
    }
    
    // Process all "at beginning of combat" triggers
    for (entity, trigger) in trigger_sources.iter() {
        if let TriggerCondition::BeginningOfCombat { controller_only } = trigger.condition {
            let should_trigger = if controller_only {
                // Only trigger for the active player's permanents
                // Implementation details omitted
                true
            } else {
                // Trigger for all permanents with this trigger
                true
            };
            
            if should_trigger {
                ability_triggers.queue.push_back(AbilityTriggerEvent {
                    source: entity,
                    trigger: trigger.clone(),
                    targets: Vec::new(), // Will be filled later in target selection
                });
            }
        }
    }
}
}

Multiplayer Considerations

In Commander, the Beginning of Combat step needs to handle multiplayer-specific considerations:

#![allow(unused)]
fn main() {
pub fn multiplayer_beginning_of_combat(
    turn_manager: Res<TurnManager>,
    player_query: Query<(Entity, &Player)>,
    mut game_events: EventWriter<GameEvent>,
) {
    // Only run during Beginning of Combat
    if turn_manager.current_phase != Phase::Combat(CombatStep::BeginningOfCombat) {
        return;
    }
    
    // Notify all players about the beginning of combat
    let active_player = turn_manager.get_active_player();
    
    // Broadcast to all players
    for (player_entity, _) in player_query.iter() {
        game_events.send(GameEvent::PhaseChange {
            phase: Phase::Combat(CombatStep::BeginningOfCombat),
            player: active_player,
            notification_target: player_entity,
        });
    }
}
}

Ability Types in Beginning of Combat

Static Abilities

Static abilities that specifically affect combat are evaluated during this step:

#![allow(unused)]
fn main() {
pub fn evaluate_combat_static_abilities(
    mut commands: Commands,
    turn_manager: Res<TurnManager>,
    static_ability_query: Query<(Entity, &StaticAbility)>,
    creature_query: Query<(Entity, &Creature, &Controllable)>,
) {
    // Only run during Beginning of Combat
    if turn_manager.current_phase != Phase::Combat(CombatStep::BeginningOfCombat) {
        return;
    }
    
    // Apply "can't attack" effects
    for (entity, static_ability) in static_ability_query.iter() {
        match static_ability.effect {
            StaticEffect::CantAttack { target, condition } => {
                // Apply can't attack markers to appropriate creatures
                for (creature_entity, _, controllable) in creature_query.iter() {
                    if target.matches(creature_entity) && condition.is_met(creature_entity) {
                        commands.entity(creature_entity).insert(CantAttack {
                            source: entity,
                            duration: static_ability.duration,
                        });
                    }
                }
            },
            StaticEffect::CantBlock { target, condition } => {
                // Apply can't block markers to appropriate creatures
                for (creature_entity, _, controllable) in creature_query.iter() {
                    if target.matches(creature_entity) && condition.is_met(creature_entity) {
                        commands.entity(creature_entity).insert(CantBlock {
                            source: entity,
                            duration: static_ability.duration,
                        });
                    }
                }
            },
            // Other combat-relevant static effects...
            _ => {}
        }
    }
}
}

Triggered Abilities

Abilities that trigger at the beginning of combat:

#![allow(unused)]
fn main() {
#[derive(Component)]
pub struct AbilityTrigger {
    pub condition: TriggerCondition,
    pub effect: TriggeredEffect,
}

#[derive(Clone)]
pub enum TriggerCondition {
    BeginningOfCombat { controller_only: bool },
    // Other trigger conditions...
}

// Example of a "beginning of combat" triggered ability
pub fn assemble_megatron_trigger(
    turn_manager: Res<TurnManager>,
    megatron_query: Query<(Entity, &Controllable), With<Megatron>>,
    mut game_events: EventWriter<GameEvent>,
    mut ability_triggers: ResMut<AbilityTriggerQueue>,
) {
    // Only run during Beginning of Combat
    if turn_manager.current_phase != Phase::Combat(CombatStep::BeginningOfCombat) {
        return;
    }
    
    let active_player = turn_manager.get_active_player();
    
    for (entity, controllable) in megatron_query.iter() {
        // Only trigger for the active player's Megatron
        if controllable.controller == active_player {
            ability_triggers.queue.push_back(AbilityTriggerEvent {
                source: entity,
                trigger: AbilityTrigger {
                    condition: TriggerCondition::BeginningOfCombat { controller_only: true },
                    effect: TriggeredEffect::AssembleMegatron,
                },
                targets: Vec::new(),
            });
        }
    }
}
}

Edge Cases and Special Interactions

Cleanup Actions

Sometimes effects need to be cleaned up at the beginning of combat:

#![allow(unused)]
fn main() {
pub fn clear_end_of_combat_effects(
    mut commands: Commands,
    effect_query: Query<(Entity, &EndOfCombatEffect)>,
) {
    for (entity, effect) in effect_query.iter() {
        if effect.cleanup_at_next_beginning_of_combat {
            // Remove the effect component
            commands.entity(entity).remove::<EndOfCombatEffect>();
            
            // Apply any cleanup logic specific to this effect
            match effect.effect_type {
                EndOfCombatEffectType::TemporaryPowerBoost => {
                    commands.entity(entity).remove::<PowerToughnessModifier>();
                },
                EndOfCombatEffectType::TemporaryAbilityGrant => {
                    commands.entity(entity).remove::<GrantedAbility>();
                },
                // Other effect types...
            }
        }
    }
}
}

"Until Your Next Combat" Effects

Some effects last until a player's next combat phase begins:

#![allow(unused)]
fn main() {
pub fn process_until_next_combat_effects(
    mut commands: Commands,
    turn_manager: Res<TurnManager>,
    effect_query: Query<(Entity, &UntilNextCombatEffect, &Controllable)>,
) {
    // Only run during Beginning of Combat
    if turn_manager.current_phase != Phase::Combat(CombatStep::BeginningOfCombat) {
        return;
    }
    
    let active_player = turn_manager.get_active_player();
    
    // Find and remove effects that should end
    for (entity, effect, controllable) in effect_query.iter() {
        if controllable.controller == active_player {
            commands.entity(entity).remove::<UntilNextCombatEffect>();
            
            // Apply any cleanup logic specific to this effect
            match effect.effect_type {
                // Implementation details...
                _ => {}
            }
        }
    }
}
}

Testing Strategy

Unit Tests

#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
    use super::*;
    
    #[test]
    fn test_beginning_of_combat_triggers() {
        let mut app = App::new();
        app.add_systems(Update, beginning_of_combat_triggers);
        app.init_resource::<AbilityTriggerQueue>();
        
        // Create a simple trigger
        let trigger_entity = app.world.spawn((
            AbilityTrigger {
                condition: TriggerCondition::BeginningOfCombat { controller_only: false },
                effect: TriggeredEffect::DrawCard { count: 1 },
            },
        )).id();
        
        // Set up turn manager with Beginning of Combat phase
        let mut turn_manager = TurnManager::default();
        turn_manager.current_phase = Phase::Combat(CombatStep::BeginningOfCombat);
        app.insert_resource(turn_manager);
        
        // Run the system
        app.update();
        
        // Check if trigger was added to queue
        let ability_triggers = app.world.resource::<AbilityTriggerQueue>();
        assert_eq!(ability_triggers.queue.len(), 1);
        
        let trigger_event = ability_triggers.queue.front().unwrap();
        assert_eq!(trigger_event.source, trigger_entity);
    }
    
    #[test]
    fn test_beginning_of_combat_system_phase_progression() {
        let mut app = App::new();
        app.add_systems(Update, beginning_of_combat_system);
        app.add_event::<GameEvent>();
        app.init_resource::<PrioritySystem>();
        app.init_resource::<NextState<Phase>>();
        
        // Set up active player
        let active_player = app.world.spawn((Player::default(), ActivePlayer)).id();
        
        // Set up turn manager with Beginning of Combat phase
        let mut turn_manager = TurnManager::default();
        turn_manager.current_phase = Phase::Combat(CombatStep::BeginningOfCombat);
        app.insert_resource(turn_manager);
        
        // Set up priority system to indicate all players have passed
        let mut priority_system = PrioritySystem::default();
        priority_system.priority_given = true;
        priority_system.current_player_index = 0;
        priority_system.all_passed = true;
        priority_system.stack_changed = false;
        app.insert_resource(priority_system);
        
        // Run the system
        app.update();
        
        // Check that phase advanced to Declare Attackers
        let next_phase = app.world.resource::<NextState<Phase>>();
        assert_eq!(next_phase.0, Some(Phase::Combat(CombatStep::DeclareAttackers)));
    }
    
    // Additional unit tests...
}
}

Integration Tests

#![allow(unused)]
fn main() {
#[cfg(test)]
mod integration_tests {
    use super::*;
    
    #[test]
    fn test_beginning_of_combat_workflow() {
        let mut app = App::new();
        
        // Add all relevant systems
        app.add_systems(Update, (
            beginning_of_combat_system,
            beginning_of_combat_triggers,
            evaluate_combat_static_abilities,
            process_until_next_combat_effects,
            clear_end_of_combat_effects,
        ));
        
        // Set up game state
        // Implementation details omitted for brevity
        
        // Run several updates to simulate entire beginning of combat step
        for _ in 0..5 {
            app.update();
        }
        
        // Verify that all systems executed correctly
        // Implementation details omitted for brevity
    }
    
    // Additional integration tests...
}
}

UI Considerations

During the Beginning of Combat step, the user interface should:

  1. Clearly indicate the current phase (Beginning of Combat)
  2. Show which player has priority
  3. Highlight creatures that could potentially attack
  4. Display any relevant triggered abilities that are waiting to go on the stack

This can be implemented with the following system:

#![allow(unused)]
fn main() {
pub fn update_beginning_of_combat_ui(
    turn_manager: Res<TurnManager>,
    priority_system: Res<PrioritySystem>,
    creature_query: Query<(Entity, &Creature, &Controllable)>,
    ability_triggers: Res<AbilityTriggerQueue>,
    mut ui_state: ResMut<UiState>,
) {
    // Only run during Beginning of Combat
    if turn_manager.current_phase != Phase::Combat(CombatStep::BeginningOfCombat) {
        return;
    }
    
    // Update phase display
    ui_state.current_phase_text = "Beginning of Combat".to_string();
    
    // Show player with priority
    ui_state.player_with_priority = priority_system.get_player_with_priority();
    
    // Highlight potential attackers
    let active_player = turn_manager.get_active_player();
    for (entity, creature, controllable) in creature_query.iter() {
        if controllable.controller == active_player && creature.can_attack() {
            ui_state.potential_attackers.insert(entity);
        }
    }
    
    // Display waiting triggers
    ui_state.pending_triggers = ability_triggers.queue.iter()
        .map(|trigger| (trigger.source, trigger.trigger.clone()))
        .collect();
}
}

Performance Considerations

The Beginning of Combat step generally has fewer performance implications than subsequent combat steps, but we should still be mindful of:

  1. Efficiently processing "beginning of combat" triggers, which could be numerous
  2. Minimizing component queries by combining related operations
  3. Only performing combat-specific calculations once per beginning of combat step

Conclusion

The Beginning of Combat step, while often quickly passed through in many games, serves a crucial role in the overall combat structure. It provides the last opportunity for players to act before attackers are declared, and it's the time when many powerful combat-related triggered abilities occur. A robust implementation of this step ensures that all cards function correctly and players have appropriate opportunities to respond before the action of combat truly begins.

Declare Attackers Step

Overview

The Declare Attackers step is where the active player selects which creatures will attack and which opponents or planeswalkers they will target. In Commander, this step is particularly complex due to the multiplayer nature of the format, allowing attacks against different opponents simultaneously. This document details the implementation of the Declare Attackers step in our game engine.

Core Implementation

Phase Structure

The Declare Attackers step follows the Beginning of Combat step in the combat phase sequence:

#![allow(unused)]
fn main() {
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CombatStep {
    BeginningOfCombat,
    DeclareAttackers,
    DeclareBlockers,
    CombatDamage,
    EndOfCombat,
}
}

Declare Attackers System

The core system that handles the Declare Attackers step:

#![allow(unused)]
fn main() {
pub fn declare_attackers_system(
    mut commands: Commands,
    turn_manager: Res<TurnManager>,
    mut game_events: EventWriter<GameEvent>,
    mut next_phase: ResMut<NextState<Phase>>,
    mut priority_system: ResMut<PrioritySystem>,
    mut combat_system: ResMut<CombatSystem>,
    mut attack_declarations: EventReader<AttackDeclarationEvent>,
) {
    // Only run during Declare Attackers step
    if turn_manager.current_phase != Phase::Combat(CombatStep::DeclareAttackers) {
        return;
    }

    // If this is the first time entering the step
    if !priority_system.priority_given {
        // Emit Declare Attackers event
        let active_player = turn_manager.get_active_player();
        game_events.send(GameEvent::DeclareAttackersStep {
            player: active_player,
        });
        
        // Process attack requirements and restrictions
        commands.run_system(process_attack_requirements);
        
        // Grant priority to active player for attack declarations
        priority_system.grant_initial_priority();
    }
    
    // Process any attack declarations
    for event in attack_declarations.iter() {
        process_attack_declaration(&mut combat_system, event, &mut game_events);
    }
    
    // If active player has passed priority with attack declarations finalized
    if priority_system.active_player_passed && combat_system.attack_declarations_finalized {
        // Process post-declaration triggers
        commands.run_system(process_attack_triggers);
        
        // Reset priority for all players to respond to attacks
        priority_system.reset_with_active_player_priority();
    }
    
    // If all players have passed priority and the stack is empty
    if priority_system.all_players_passed() && priority_system.stack.is_empty() {
        // If at least one attacker was declared
        if !combat_system.attackers.is_empty() {
            // Proceed to Declare Blockers step
            next_phase.set(Phase::Combat(CombatStep::DeclareBlockers));
        } else {
            // Skip to End of Combat if no attackers
            next_phase.set(Phase::Combat(CombatStep::EndOfCombat));
        }
        priority_system.priority_given = false;
    }
}

// Helper function to process an attack declaration
fn process_attack_declaration(
    combat_system: &mut CombatSystem,
    event: &AttackDeclarationEvent,
    game_events: &mut EventWriter<GameEvent>,
) {
    let AttackDeclarationEvent { attacker, defender } = event;
    
    // Validate attack declaration
    if let Some(reason) = validate_attack(*attacker, *defender, combat_system) {
        game_events.send(GameEvent::InvalidAttackDeclaration {
            attacker: *attacker,
            defender: *defender,
            reason,
        });
        return;
    }
    
    // Record the attack in the combat system
    combat_system.attackers.insert(*attacker, AttackData {
        attacker: *attacker,
        defender: *defender,
        is_commander: false, // Will be updated by a separate system
        requirements: Vec::new(),
        restrictions: Vec::new(),
    });
    
    // Emit attack declaration event
    game_events.send(GameEvent::AttackDeclared {
        attacker: *attacker,
        defender: *defender,
    });
}
}

Attack Validation

Attacks must be validated according to various rules and restrictions:

#![allow(unused)]
fn main() {
fn validate_attack(
    attacker: Entity,
    defender: Entity,
    combat_system: &CombatSystem,
) -> Option<String> {
    // Check if creature is able to attack
    if let Some(restrictions) = combat_system.attack_restrictions.get(&attacker) {
        for restriction in restrictions {
            match restriction {
                AttackRestriction::CantAttack => {
                    return Some("Creature cannot attack".to_string());
                },
                AttackRestriction::CantAttackPlayer(player) => {
                    if *player == defender {
                        return Some("Creature cannot attack this player".to_string());
                    }
                },
                AttackRestriction::CantAttackThisTurn => {
                    return Some("Creature cannot attack this turn".to_string());
                },
                // Other restrictions...
            }
        }
    }
    
    // Check defender-specific restrictions
    if let Some(restrictions) = combat_system.defender_restrictions.get(&defender) {
        for restriction in restrictions {
            match restriction {
                DefenderRestriction::CantBeAttacked => {
                    return Some("This player or planeswalker cannot be attacked".to_string());
                },
                DefenderRestriction::CantBeAttackedBy(condition) => {
                    if condition.matches(attacker) {
                        return Some("This creature cannot attack this defender".to_string());
                    }
                },
                // Other restrictions...
            }
        }
    }
    
    // All checks passed
    None
}
}

Attack Requirements

Some creatures have requirements that dictate how they must attack:

#![allow(unused)]
fn main() {
pub fn process_attack_requirements(
    mut combat_system: ResMut<CombatSystem>,
    creature_query: Query<(Entity, &Creature, &Controllable)>,
    active_player_query: Query<Entity, With<ActivePlayer>>,
    mut game_events: EventWriter<GameEvent>,
) {
    let active_player = active_player_query.single();
    
    // Process creatures with attack requirements
    for (entity, creature, controllable) in creature_query.iter() {
        // Only check creatures controlled by active player
        if controllable.controller != active_player {
            continue;
        }
        
        // Check if creature has attack requirements
        if let Some(requirements) = combat_system.attack_requirements.get(&entity) {
            for requirement in requirements {
                match requirement {
                    AttackRequirement::MustAttack => {
                        // Creature must attack if able
                        combat_system.add_required_attacker(entity);
                        
                        game_events.send(GameEvent::AttackRequirement {
                            creature: entity,
                            requirement: "Must attack if able".to_string(),
                        });
                    },
                    AttackRequirement::MustAttackSpecificPlayer(player) => {
                        // Creature must attack a specific player
                        combat_system.add_required_attacker(entity);
                        combat_system.add_required_defender(*player, entity);
                        
                        game_events.send(GameEvent::AttackRequirement {
                            creature: entity,
                            requirement: format!("Must attack player {:?} if able", player),
                        });
                    },
                    // Other requirements...
                }
            }
        }
    }
}
}

Multiplayer Considerations

In Commander, the active player can attack multiple opponents in the same combat phase:

#![allow(unused)]
fn main() {
pub fn validate_multiplayer_attacks(
    combat_system: Res<CombatSystem>,
    turn_manager: Res<TurnManager>,
    player_query: Query<(Entity, &Player)>,
    mut game_events: EventWriter<GameEvent>,
) {
    let active_player = turn_manager.get_active_player();
    
    // Group attacks by defender
    let mut attacks_by_defender: HashMap<Entity, Vec<Entity>> = HashMap::new();
    
    for (attacker, attack_data) in &combat_system.attackers {
        attacks_by_defender.entry(attack_data.defender)
            .or_insert_with(Vec::new)
            .push(*attacker);
    }
    
    // Verify all defenders are opponents
    for (defender, attackers) in &attacks_by_defender {
        // Skip planeswalkers (they're handled separately)
        if let Ok((_, player)) = player_query.get(*defender) {
            // Verify defender is not the active player
            if *defender == active_player {
                game_events.send(GameEvent::InvalidAttackTarget {
                    attacker: attackers[0], // Just report one of the attackers
                    defender: *defender,
                    reason: "Cannot attack yourself".to_string(),
                });
            }
        }
    }
    
    // Log all attack declarations for game history
    game_events.send(GameEvent::MultiplayerAttacksDeclared {
        active_player,
        attacks_by_defender: attacks_by_defender.clone(),
    });
}
}

Commander-Specific Implementations

Commander Attack Tracking

When a commander attacks, it needs to be specially tracked for commander damage:

#![allow(unused)]
fn main() {
pub fn track_commander_attacks(
    mut combat_system: ResMut<CombatSystem>,
    commander_query: Query<Entity, With<Commander>>,
) {
    // Find all commanders that are attacking
    for (attacker, attack_data) in combat_system.attackers.iter_mut() {
        if commander_query.contains(*attacker) {
            attack_data.is_commander = true;
        }
    }
}
}

Goad Implementation

Goad is a Commander-specific mechanic that forces creatures to attack:

#![allow(unused)]
fn main() {
#[derive(Component)]
pub struct Goaded {
    pub source: Entity,
    pub until_end_of_turn: bool,
}

pub fn apply_goad_requirements(
    mut combat_system: ResMut<CombatSystem>,
    goaded_query: Query<(Entity, &Goaded, &Controllable)>,
    turn_manager: Res<TurnManager>,
) {
    let active_player = turn_manager.get_active_player();
    
    // Find all goaded creatures controlled by the active player
    for (entity, goaded, controllable) in goaded_query.iter() {
        if controllable.controller == active_player {
            // Add attack requirement - must attack if able
            combat_system.add_attack_requirement(entity, AttackRequirement::MustAttack);
            
            // Add attack restriction - can't attack the goad source
            combat_system.add_attack_restriction(entity, AttackRestriction::CantAttackPlayer(goaded.source));
        }
    }
}
}

Triggered Abilities

Attack Triggers

When attackers are declared, various triggered abilities might occur:

#![allow(unused)]
fn main() {
pub fn process_attack_triggers(
    turn_manager: Res<TurnManager>,
    combat_system: Res<CombatSystem>,
    mut ability_triggers: ResMut<AbilityTriggerQueue>,
    trigger_sources: Query<(Entity, &AbilityTrigger, &Controllable)>,
) {
    // Process "when this creature attacks" triggers
    for (attacker, _) in combat_system.attackers.iter() {
        if let Ok((entity, trigger, _)) = trigger_sources.get(*attacker) {
            if let TriggerCondition::WhenAttacks = trigger.condition {
                ability_triggers.queue.push_back(AbilityTriggerEvent {
                    source: entity,
                    trigger: trigger.clone(),
                    targets: Vec::new(),
                });
            }
        }
    }
    
    // Process "whenever a creature attacks" triggers
    for (entity, trigger, controllable) in trigger_sources.iter() {
        if let TriggerCondition::WheneverCreatureAttacks { controller_only } = trigger.condition {
            // Only consider triggers that should fire based on controller
            let should_trigger = if controller_only {
                // Check if any of the attacking creatures are controlled by this trigger's controller
                combat_system.attackers.iter().any(|(_, attack_data)| {
                    // Simplified for brevity, actual implementation would check creature controllers
                    true
                })
            } else {
                // Trigger for any attacking creature
                !combat_system.attackers.is_empty()
            };
            
            if should_trigger {
                ability_triggers.queue.push_back(AbilityTriggerEvent {
                    source: entity,
                    trigger: trigger.clone(),
                    targets: Vec::new(),
                });
            }
        }
    }
}
}

Exert Mechanic

Exert is a mechanic that gives benefits in exchange for the creature not untapping:

#![allow(unused)]
fn main() {
#[derive(Component)]
pub struct Exert {
    pub duration: ExertDuration,
    pub effect: ExertEffect,
}

pub enum ExertDuration {
    NextUntapStep,
    NextUntapStepController,
}

pub fn handle_exert_choices(
    mut commands: Commands,
    mut exert_choices: EventReader<ExertChoiceEvent>,
    mut game_events: EventWriter<GameEvent>,
) {
    for event in exert_choices.iter() {
        let ExertChoiceEvent { creature, exert } = event;
        
        if *exert {
            // Mark the creature as exerted
            commands.entity(*creature).insert(Exerted {
                until_next_untap_step: true,
            });
            
            // Apply exert effect
            // Implementation details omitted
            
            game_events.send(GameEvent::CreatureExerted {
                creature: *creature,
            });
        }
    }
}
}

State Tracking

Once all attackers are declared, we need to update the game state:

#![allow(unused)]
fn main() {
pub fn update_creature_state_on_attack(
    mut commands: Commands,
    combat_system: Res<CombatSystem>,
    mut creature_query: Query<(Entity, &mut Creature)>,
) {
    // Update all attacking creatures
    for (attacker, attack_data) in combat_system.attackers.iter() {
        if let Ok((_, mut creature)) = creature_query.get_mut(*attacker) {
            // Mark creature as attacking
            creature.attacking = Some(attack_data.defender);
            
            // Add the Attacking component for faster queries
            commands.entity(*attacker).insert(Attacking {
                defender: attack_data.defender,
            });
            
            // Tap the creature unless it has vigilance
            if !creature.has_ability(CreatureAbility::Vigilance) {
                commands.entity(*attacker).insert(Tapped(true));
            }
        }
    }
}
}

Edge Cases and Special Interactions

Attack Redirection Effects

Some effects can redirect attacks to different players or planeswalkers:

#![allow(unused)]
fn main() {
pub fn handle_attack_redirection(
    mut combat_system: ResMut<CombatSystem>,
    redirection_effects: Query<(Entity, &AttackRedirection)>,
    mut game_events: EventWriter<GameEvent>,
) {
    // Process any attack redirection effects
    let redirections: Vec<(Entity, Entity, Entity)> = combat_system.attackers
        .iter()
        .filter_map(|(attacker, attack_data)| {
            for (effect_entity, redirection) in redirection_effects.iter() {
                if redirection.original_defender == attack_data.defender 
                   && redirection.applies_to(*attacker) {
                    return Some((*attacker, attack_data.defender, redirection.new_defender));
                }
            }
            None
        })
        .collect();
    
    // Apply redirections
    for (attacker, original_defender, new_defender) in redirections {
        if let Some(attack_data) = combat_system.attackers.get_mut(&attacker) {
            // Log the redirection
            game_events.send(GameEvent::AttackRedirected {
                attacker,
                original_defender,
                new_defender,
            });
            
            // Update the attack target
            attack_data.defender = new_defender;
        }
    }
}
}

Attack Cost Effects

Some effects add costs to attacking:

#![allow(unused)]
fn main() {
pub fn handle_attack_costs(
    mut commands: Commands,
    mut combat_system: ResMut<CombatSystem>,
    cost_effects: Query<(Entity, &AttackCost)>,
    mut game_events: EventWriter<GameEvent>,
    mut mana_events: EventWriter<ManaPaymentEvent>,
) {
    // Process any attack cost effects
    let costs: Vec<(Entity, Entity, AttackCostType)> = combat_system.attackers
        .iter()
        .filter_map(|(attacker, attack_data)| {
            for (effect_entity, cost) in cost_effects.iter() {
                if cost.applies_to(*attacker, attack_data.defender) {
                    return Some((*attacker, effect_entity, cost.cost_type.clone()));
                }
            }
            None
        })
        .collect();
    
    // Apply costs
    for (attacker, cost_source, cost_type) in costs {
        match cost_type {
            AttackCostType::Mana(cost) => {
                // Request mana payment
                mana_events.send(ManaPaymentEvent {
                    source: attacker,
                    reason: PaymentReason::AttackCost { creature: attacker },
                    cost,
                });
            },
            AttackCostType::Life(amount) => {
                // Implementation for life payment
                // Details omitted
            },
            // Other cost types...
        }
        
        game_events.send(GameEvent::AttackCostApplied {
            attacker,
            cost_source,
            cost_description: format!("{:?}", cost_type),
        });
    }
}
}

Testing Strategy

Unit Tests

#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
    use super::*;
    
    #[test]
    fn test_attack_validation() {
        // Test with a creature that can attack
        let result = validate_attack(
            /* mock entities and combat system */
            Entity::from_raw(1),
            Entity::from_raw(2),
            &CombatSystem::default(),
        );
        assert_eq!(result, None, "Valid attack should return None");
        
        // Test with a creature that can't attack
        let mut combat_system = CombatSystem::default();
        combat_system.add_attack_restriction(
            Entity::from_raw(1),
            AttackRestriction::CantAttack,
        );
        
        let result = validate_attack(
            Entity::from_raw(1),
            Entity::from_raw(2),
            &combat_system,
        );
        assert!(result.is_some(), "Invalid attack should return an error message");
    }
    
    #[test]
    fn test_commander_attack_tracking() {
        let mut app = App::new();
        app.add_systems(Update, track_commander_attacks);
        
        // Set up a commander and a regular creature
        let commander = app.world.spawn((Creature::default(), Commander)).id();
        let regular_creature = app.world.spawn(Creature::default()).id();
        
        // Set up combat system with both attacking
        let mut combat_system = CombatSystem::default();
        combat_system.attackers.insert(commander, AttackData {
            attacker: commander,
            defender: Entity::from_raw(3),
            is_commander: false, // Should be updated by system
            requirements: Vec::new(),
            restrictions: Vec::new(),
        });
        combat_system.attackers.insert(regular_creature, AttackData {
            attacker: regular_creature,
            defender: Entity::from_raw(3),
            is_commander: false,
            requirements: Vec::new(),
            restrictions: Vec::new(),
        });
        app.insert_resource(combat_system);
        
        // Run the system
        app.update();
        
        // Check commander status
        let combat_system = app.world.resource::<CombatSystem>();
        assert!(combat_system.attackers[&commander].is_commander, "Commander should be marked as such when attacking");
        assert!(!combat_system.attackers[&regular_creature].is_commander, "Regular creature should not be marked as a commander");
    }
    
    #[test]
    fn test_goad_mechanic() {
        let mut app = App::new();
        app.add_systems(Update, apply_goad_requirements);
        
        // Set up test environment with a goaded creature
        let active_player = app.world.spawn(Player::default()).id();
        let opponent = app.world.spawn(Player::default()).id();
        
        let goaded_creature = app.world.spawn((
            Creature::default(),
            Controllable { controller: active_player },
            Goaded { source: opponent, until_end_of_turn: true },
        )).id();
        
        // Set up turn manager
        let mut turn_manager = TurnManager::default();
        turn_manager.active_player_index = 0;
        turn_manager.player_order = vec![active_player, opponent];
        app.insert_resource(turn_manager);
        
        // Set up combat system
        let combat_system = CombatSystem::default();
        app.insert_resource(combat_system);
        
        // Run the system
        app.update();
        
        // Check results
        let combat_system = app.world.resource::<CombatSystem>();
        
        // Goaded creature should be required to attack
        let has_must_attack = combat_system.attack_requirements.get(&goaded_creature)
            .map_or(false, |reqs| reqs.iter().any(|req| matches!(req, AttackRequirement::MustAttack)));
        assert!(has_must_attack, "Goaded creature should have MustAttack requirement");
        
        // Goaded creature should not be able to attack the goad source
        let has_cant_attack_source = combat_system.attack_restrictions.get(&goaded_creature)
            .map_or(false, |reqs| reqs.iter().any(|req| matches!(req, AttackRestriction::CantAttackPlayer(p) if *p == opponent)));
        assert!(has_cant_attack_source, "Goaded creature should not be able to attack goad source");
    }
    
    // Additional unit tests...
}
}

Integration Tests

#![allow(unused)]
fn main() {
#[cfg(test)]
mod integration_tests {
    use super::*;
    
    #[test]
    fn test_declare_attackers_workflow() {
        let mut app = App::new();
        
        // Add all relevant systems
        app.add_systems(Update, (
            declare_attackers_system,
            process_attack_requirements,
            track_commander_attacks,
            process_attack_triggers,
            update_creature_state_on_attack,
        ));
        
        // Set up game state with players and creatures
        // Implementation details omitted for brevity
        
        // Simulate player declaring attackers
        app.world.resource_mut::<Events<AttackDeclarationEvent>>().send(
            AttackDeclarationEvent {
                attacker: Entity::from_raw(1),
                defender: Entity::from_raw(2),
            }
        );
        
        // Run update to process declarations
        app.update();
        
        // Verify attackers are properly recorded and state is updated
        // Implementation details omitted for brevity
    }
    
    #[test]
    fn test_multiplayer_attack_declarations() {
        let mut app = App::new();
        
        // Set up a multiplayer environment with three players
        let active_player = app.world.spawn(Player::default()).id();
        let opponent1 = app.world.spawn(Player::default()).id();
        let opponent2 = app.world.spawn(Player::default()).id();
        
        // Set up creatures for the active player
        let creature1 = app.world.spawn(Creature::default()).id();
        let creature2 = app.world.spawn(Creature::default()).id();
        
        // Set up turn manager
        let mut turn_manager = TurnManager::default();
        turn_manager.active_player_index = 0;
        turn_manager.player_order = vec![active_player, opponent1, opponent2];
        app.insert_resource(turn_manager);
        
        // Set up combat system with attacks against different opponents
        let mut combat_system = CombatSystem::default();
        combat_system.attackers.insert(creature1, AttackData {
            attacker: creature1,
            defender: opponent1,
            is_commander: false,
            requirements: Vec::new(),
            restrictions: Vec::new(),
        });
        combat_system.attackers.insert(creature2, AttackData {
            attacker: creature2,
            defender: opponent2,
            is_commander: false,
            requirements: Vec::new(),
            restrictions: Vec::new(),
        });
        app.insert_resource(combat_system);
        
        // Add validate_multiplayer_attacks system
        app.add_systems(Update, validate_multiplayer_attacks);
        app.add_event::<GameEvent>();
        
        // Run the system
        app.update();
        
        // Check for multiplayer attack event
        let mut found_event = false;
        let events = app.world.resource::<Events<GameEvent>>();
        let mut reader = events.get_reader();
        
        for event in reader.iter(events) {
            if let GameEvent::MultiplayerAttacksDeclared { .. } = event {
                found_event = true;
                break;
            }
        }
        
        assert!(found_event, "Multiplayer attack event should be emitted");
    }
    
    // Additional integration tests...
}
}

UI Considerations

The UI during the Declare Attackers step needs to clearly communicate various states:

#![allow(unused)]
fn main() {
pub fn update_declare_attackers_ui(
    turn_manager: Res<TurnManager>,
    combat_system: Res<CombatSystem>,
    creature_query: Query<(Entity, &Creature, &Controllable)>,
    player_query: Query<Entity, With<Player>>,
    mut ui_state: ResMut<UiState>,
) {
    // Only run during Declare Attackers step
    if turn_manager.current_phase != Phase::Combat(CombatStep::DeclareAttackers) {
        return;
    }
    
    // Update phase display
    ui_state.current_phase_text = "Declare Attackers".to_string();
    
    // Get active player
    let active_player = turn_manager.get_active_player();
    
    // Highlight potential attackers
    ui_state.potential_attackers.clear();
    for (entity, creature, controllable) in creature_query.iter() {
        if controllable.controller == active_player && creature.can_attack() {
            ui_state.potential_attackers.insert(entity);
            
            // Mark creatures that must attack
            if let Some(requirements) = combat_system.attack_requirements.get(&entity) {
                if requirements.iter().any(|req| matches!(req, AttackRequirement::MustAttack)) {
                    ui_state.creatures_with_requirements.insert(entity, "Must attack if able".to_string());
                }
            }
        }
    }
    
    // Highlight potential defenders
    ui_state.potential_defenders.clear();
    for entity in player_query.iter() {
        if entity != active_player {
            ui_state.potential_defenders.insert(entity);
        }
    }
    
    // Show current attack declarations
    ui_state.current_attacks.clear();
    for (attacker, attack_data) in combat_system.attackers.iter() {
        ui_state.current_attacks.insert(*attacker, attack_data.defender);
    }
}
}

Performance Considerations

  1. Efficient Attack Validation: The validation of attacks should be optimized to avoid redundant checks.

  2. Caching Attack Results: Once attack declarations are finalized, the results can be cached for use in subsequent steps.

  3. Parallel Processing: For games with many attackers, processing attack triggers could be done in parallel.

  4. Minimize Component Access: Group related queries to minimize entity access operations.

Conclusion

The Declare Attackers step is a critical part of the combat phase in Commander. A robust implementation ensures that all game rules are properly enforced, including multiplayer-specific mechanics like Goad. By handling attack declarations, restrictions, requirements, and triggers correctly, we provide the foundation for a smooth and accurate combat resolution process.

Declare Blockers Step

Overview

The Declare Blockers step is the phase of combat where defending players assign their creatures to block attacking creatures. In Commander, this step can be particularly complex due to the multiplayer nature of the format, where multiple players may be defending against attacks simultaneously. This document outlines the implementation of the Declare Blockers step in our game engine.

Core Implementation

Phase Structure

The Declare Blockers step follows the Declare Attackers step in the combat phase sequence:

#![allow(unused)]
fn main() {
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CombatStep {
    BeginningOfCombat,
    DeclareAttackers,
    DeclareBlockers,
    CombatDamage,
    EndOfCombat,
}
}

Declare Blockers System

The core system that handles the Declare Blockers step:

#![allow(unused)]
fn main() {
pub fn declare_blockers_system(
    mut commands: Commands,
    turn_manager: Res<TurnManager>,
    mut game_events: EventWriter<GameEvent>,
    mut next_phase: ResMut<NextState<Phase>>,
    mut priority_system: ResMut<PrioritySystem>,
    combat_system: Res<CombatSystem>,
    mut block_declarations: EventReader<BlockDeclarationEvent>,
) {
    // Only run during Declare Blockers step
    if turn_manager.current_phase != Phase::Combat(CombatStep::DeclareBlockers) {
        return;
    }

    // If this is the first time entering the step
    if !priority_system.priority_given {
        // Emit Declare Blockers event
        game_events.send(GameEvent::DeclareBlockersStep);
        
        // Process block requirements and restrictions
        commands.run_system(process_block_requirements);
        
        // Create turn order for block declarations
        priority_system.set_player_order_for_blockers(combat_system.attacked_players());
        
        // Grant priority to first defending player
        priority_system.grant_initial_priority();
    }
    
    // Process any block declarations
    for event in block_declarations.iter() {
        process_block_declaration(&mut commands, event, &combat_system, &mut game_events);
    }
    
    // If all defending players have passed priority with block declarations finalized
    if priority_system.all_defenders_passed && combat_system.block_declarations_finalized {
        // Process post-declaration triggers
        commands.run_system(process_block_triggers);
        
        // Reset priority for all players to respond to blocks
        priority_system.reset_with_active_player_priority();
    }
    
    // If all players have passed priority and the stack is empty
    if priority_system.all_players_passed() && priority_system.stack.is_empty() {
        // Proceed to Combat Damage step
        next_phase.set(Phase::Combat(CombatStep::CombatDamage));
        priority_system.priority_given = false;
    }
}

// Helper function to process a block declaration
fn process_block_declaration(
    commands: &mut Commands,
    event: &BlockDeclarationEvent,
    combat_system: &CombatSystem,
    game_events: &mut EventWriter<GameEvent>,
) {
    let BlockDeclarationEvent { blocker, attackers } = event;
    
    // Validate block declaration
    if let Some(reason) = validate_block(*blocker, attackers, combat_system) {
        game_events.send(GameEvent::InvalidBlockDeclaration {
            blocker: *blocker,
            attackers: attackers.clone(),
            reason,
        });
        return;
    }
    
    // Record the block in the combat system
    commands.entity(*blocker).insert(Blocking {
        blocked_attackers: attackers.clone(),
    });
    
    // Emit block declaration event
    for attacker in attackers {
        game_events.send(GameEvent::BlockDeclared {
            blocker: *blocker,
            attacker: *attacker,
        });
    }
}
}

Block Validation

Blocks must be validated according to various rules and restrictions:

#![allow(unused)]
fn main() {
fn validate_block(
    blocker: Entity,
    attackers: &[Entity],
    combat_system: &CombatSystem,
) -> Option<String> {
    // Check if creature can block at all
    if let Some(restrictions) = combat_system.block_restrictions.get(&blocker) {
        for restriction in restrictions {
            match restriction {
                BlockRestriction::CantBlock => {
                    return Some("Creature cannot block".to_string());
                },
                // Other general block restrictions...
            }
        }
    }
    
    // Check if creature can block multiple attackers
    if attackers.len() > 1 {
        let can_block_multiple = check_can_block_multiple(blocker, combat_system);
        if !can_block_multiple {
            return Some("Creature cannot block multiple attackers".to_string());
        }
    }
    
    // Check attacker-specific restrictions
    for attacker in attackers {
        // Check if this attacker can be blocked by this blocker
        if let Some(restrictions) = combat_system.attacker_restrictions.get(attacker) {
            for restriction in restrictions {
                match restriction {
                    AttackerRestriction::CantBeBlocked => {
                        return Some(format!("Attacker {} cannot be blocked", attacker.index()));
                    },
                    AttackerRestriction::CantBeBlockedBy(condition) => {
                        if condition.matches(blocker) {
                            return Some(format!("Attacker {} cannot be blocked by this creature", attacker.index()));
                        }
                    },
                    // Other attacker-specific restrictions...
                }
            }
        }
    }
    
    // Check special blocking requirements
    if let Some(requirements) = combat_system.attacker_block_requirements.get(&attackers[0]) {
        for requirement in requirements {
            match requirement {
                BlockRequirement::MustBeBlockedByAtLeast(count) => {
                    if combat_system.get_blockers_for_attacker(attackers[0]).len() + 1 < *count {
                        // This single blocker is not enough to satisfy the requirement
                        // Note: In a real implementation, this would need to check if the requirement
                        // could be satisfied with other declared blocks
                        return Some(format!("Attacker requires at least {} blockers", count));
                    }
                },
                // Other block requirements...
            }
        }
    }
    
    // All checks passed
    None
}

// Helper function to check if a creature can block multiple attackers
fn check_can_block_multiple(
    blocker: Entity,
    combat_system: &CombatSystem,
) -> bool {
    // Check if creature has a special ability that allows blocking multiple attackers
    if let Some(special_abilities) = combat_system.creature_special_abilities.get(&blocker) {
        if special_abilities.contains(&SpecialAbility::CanBlockAdditionalCreature(1)) {
            return true;
        }
        if special_abilities.contains(&SpecialAbility::CanBlockAnyNumber) {
            return true;
        }
    }
    
    // By default, creatures can only block one attacker
    false
}
}

Block Requirements

Some effects in the game can force creatures to block:

#![allow(unused)]
fn main() {
pub fn process_block_requirements(
    combat_system: Res<CombatSystem>,
    mut game_events: EventWriter<GameEvent>,
    creature_query: Query<(Entity, &Creature, &Controllable)>,
    player_query: Query<(Entity, &Player)>,
) {
    // Identify all players who are being attacked
    let attacked_players: HashSet<Entity> = combat_system.attackers
        .iter()
        .filter_map(|(_, attack_data)| {
            if player_query.contains(attack_data.defender) {
                Some(attack_data.defender)
            } else {
                None
            }
        })
        .collect();
    
    // Process creatures with block requirements
    for (entity, creature, controllable) in creature_query.iter() {
        // Only check creatures controlled by players being attacked
        if !attacked_players.contains(&controllable.controller) {
            continue;
        }
        
        // Check if creature has block requirements
        if let Some(requirements) = combat_system.block_requirements.get(&entity) {
            for requirement in requirements {
                match requirement {
                    BlockRequirement::MustBlock => {
                        // Creature must block if able
                        game_events.send(GameEvent::BlockRequirement {
                            creature: entity,
                            requirement: "Must block if able".to_string(),
                        });
                    },
                    BlockRequirement::MustBlockAttacker(attacker) => {
                        // Creature must block a specific attacker
                        if combat_system.attackers.contains_key(attacker) {
                            game_events.send(GameEvent::BlockRequirement {
                                creature: entity,
                                requirement: format!("Must block attacker {:?} if able", attacker),
                            });
                        }
                    },
                    // Other requirements...
                }
            }
        }
    }
}
}

Evasion Abilities

Evasion abilities like Flying, Menace, etc. are essential to the blocking rules:

#![allow(unused)]
fn main() {
pub fn apply_evasion_restrictions(
    mut combat_system: ResMut<CombatSystem>,
    creature_query: Query<(Entity, &Creature)>,
) {
    // Process flying
    for (entity, creature) in creature_query.iter() {
        if creature.has_ability(CreatureAbility::Flying) {
            // Flying creatures can only be blocked by creatures with flying or reach
            combat_system.add_attacker_restriction(
                entity,
                AttackerRestriction::CantBeBlockedBy(BlockerCondition::NotFlyingOrReach),
            );
        }
        
        if creature.has_ability(CreatureAbility::Menace) {
            // Menace creatures must be blocked by at least two creatures
            combat_system.add_attacker_block_requirement(
                entity,
                BlockRequirement::MustBeBlockedByAtLeast(2),
            );
        }
        
        if creature.has_ability(CreatureAbility::Fear) {
            // Fear creatures can only be blocked by black or artifact creatures
            combat_system.add_attacker_restriction(
                entity,
                AttackerRestriction::CantBeBlockedBy(BlockerCondition::NotBlackOrArtifact),
            );
        }
        
        // Implement other evasion abilities
    }
}
}

Multiplayer Considerations

In multiplayer Commander games, multiple players might need to declare blockers:

#![allow(unused)]
fn main() {
pub fn handle_multiplayer_blocks(
    combat_system: Res<CombatSystem>,
    turn_manager: Res<TurnManager>,
    player_query: Query<(Entity, &Player)>,
    mut priority_system: ResMut<PrioritySystem>,
) {
    // Group attackers by defending player
    let mut attackers_by_defender: HashMap<Entity, Vec<Entity>> = HashMap::new();
    
    for (attacker, attack_data) in &combat_system.attackers {
        if player_query.contains(attack_data.defender) {
            attackers_by_defender.entry(attack_data.defender)
                .or_insert_with(Vec::new)
                .push(*attacker);
        }
    }
    
    // Set up priority for each defending player in turn order
    let mut defender_order = Vec::new();
    for player_idx in 0..turn_manager.player_order.len() {
        let player_entity = turn_manager.player_order[player_idx];
        if attackers_by_defender.contains_key(&player_entity) {
            defender_order.push(player_entity);
        }
    }
    
    // Update priority system with defender order
    priority_system.defender_order = defender_order;
}
}

Special Blocking Rules

Multiple Blockers

When a single attacker is blocked by multiple creatures:

#![allow(unused)]
fn main() {
pub fn handle_multiple_blockers(
    mut combat_system: ResMut<CombatSystem>,
    blocking_query: Query<(Entity, &Blocking)>,
    mut game_events: EventWriter<GameEvent>,
) {
    // Build a map of attackers to their blockers
    let mut blockers_by_attacker: HashMap<Entity, Vec<Entity>> = HashMap::new();
    
    for (blocker, blocking) in blocking_query.iter() {
        for attacker in &blocking.blocked_attackers {
            blockers_by_attacker.entry(*attacker)
                .or_insert_with(Vec::new)
                .push(blocker);
        }
    }
    
    // Process attackers with multiple blockers
    for (attacker, blockers) in blockers_by_attacker.iter() {
        if blockers.len() > 1 {
            // Attacker's controller declares damage assignment order
            game_events.send(GameEvent::DamageAssignmentOrderNeeded {
                attacker: *attacker,
                blockers: blockers.clone(),
            });
        }
    }
}
}

Triggered Abilities

When blockers are declared, various triggered abilities might occur:

#![allow(unused)]
fn main() {
pub fn process_block_triggers(
    combat_system: Res<CombatSystem>,
    blocking_query: Query<(Entity, &Blocking)>,
    mut ability_triggers: ResMut<AbilityTriggerQueue>,
    trigger_sources: Query<(Entity, &AbilityTrigger)>,
) {
    // Create attacker to blocker map
    let mut blockers_by_attacker: HashMap<Entity, Vec<Entity>> = HashMap::new();
    
    for (blocker, blocking) in blocking_query.iter() {
        for attacker in &blocking.blocked_attackers {
            blockers_by_attacker.entry(*attacker)
                .or_insert_with(Vec::new)
                .push(blocker);
        }
    }
    
    // Process "when this creature blocks" triggers
    for (blocker, blocking) in blocking_query.iter() {
        if let Ok((entity, trigger)) = trigger_sources.get(blocker) {
            if let TriggerCondition::WhenBlocks = trigger.condition {
                for attacker in &blocking.blocked_attackers {
                    ability_triggers.queue.push_back(AbilityTriggerEvent {
                        source: entity,
                        trigger: trigger.clone(),
                        targets: vec![*attacker], // The attacker is the target
                    });
                }
            }
        }
    }
    
    // Process "when this creature becomes blocked" triggers
    for (attacker, blockers) in blockers_by_attacker.iter() {
        if let Ok((entity, trigger)) = trigger_sources.get(*attacker) {
            if let TriggerCondition::WhenBecomesBlocked = trigger.condition {
                ability_triggers.queue.push_back(AbilityTriggerEvent {
                    source: entity,
                    trigger: trigger.clone(),
                    targets: blockers.clone(), // All blockers are targets
                });
            }
        }
    }
    
    // Process "whenever a creature blocks" triggers
    for (entity, trigger) in trigger_sources.iter() {
        if let TriggerCondition::WheneverCreatureBlocks { controller_only } = trigger.condition {
            let should_trigger = if controller_only {
                // Only trigger for creatures controlled by the same player
                // Implementation details omitted for brevity
                true
            } else {
                // Trigger for any blocking creature
                !blocking_query.is_empty()
            };
            
            if should_trigger {
                ability_triggers.queue.push_back(AbilityTriggerEvent {
                    source: entity,
                    trigger: trigger.clone(),
                    targets: Vec::new(), // No specific targets
                });
            }
        }
    }
}
}

State Tracking

Once all blockers are declared, we need to update the game state:

#![allow(unused)]
fn main() {
pub fn update_creature_state_on_block(
    mut commands: Commands,
    blocking_query: Query<(Entity, &Blocking, &mut Creature)>,
) {
    // Update all blocking creatures
    for (entity, blocking, mut creature) in blocking_query.iter_mut() {
        // Mark creature as blocking
        creature.blocking = blocking.blocked_attackers.clone();
        
        // Tap the creature if it has vigilance for blocking
        // Note: This is not a standard MTG rule but could be a house rule or special card effect
        if creature.has_ability(CreatureAbility::VigilanceForBlocking) {
            commands.entity(entity).insert(Tapped(true));
        }
    }
}
}

Edge Cases and Special Interactions

Banding

Banding is a complex ability that affects blocks:

#![allow(unused)]
fn main() {
pub fn handle_banding(
    mut combat_system: ResMut<CombatSystem>,
    creature_query: Query<(Entity, &Creature)>,
    blocking_query: Query<(Entity, &Blocking)>,
) {
    // Find all creatures with banding
    let banding_creatures: HashSet<Entity> = creature_query
        .iter()
        .filter_map(|(entity, creature)| {
            if creature.has_ability(CreatureAbility::Banding) {
                Some(entity)
            } else {
                None
            }
        })
        .collect();
    
    // Process attackers blocked by banding creatures
    let mut defenders_control_damage_assignment: HashSet<Entity> = HashSet::new();
    
    for (blocker, blocking) in blocking_query.iter() {
        if banding_creatures.contains(&blocker) {
            // If a banding creature is blocking, the defender controls damage assignment
            for attacker in &blocking.blocked_attackers {
                defenders_control_damage_assignment.insert(*attacker);
            }
        }
    }
    
    // Update combat system
    for attacker in defenders_control_damage_assignment {
        combat_system.add_attacker_flag(attacker, AttackerFlag::DefenderControlsDamageAssignment);
    }
}
}

Protection

Protection affects blocking in various ways:

#![allow(unused)]
fn main() {
pub fn apply_protection_block_restrictions(
    mut combat_system: ResMut<CombatSystem>,
    creature_query: Query<(Entity, &Creature, &Protection)>,
) {
    for (entity, _, protection) in creature_query.iter() {
        match protection.from {
            ProtectionFrom::Color(color) => {
                // Creature with protection from a color can't be blocked by creatures of that color
                combat_system.add_attacker_restriction(
                    entity,
                    AttackerRestriction::CantBeBlockedBy(BlockerCondition::HasColor(color)),
                );
                
                // Creature with protection can't block creatures of that color
                combat_system.add_block_restriction(
                    entity,
                    BlockRestriction::CantBlockCreature(AttackerCondition::HasColor(color)),
                );
            },
            ProtectionFrom::Type(card_type) => {
                // Similar restrictions for protection from a type
                // Implementation details omitted for brevity
            },
            // Other protection types...
        }
    }
}
}

Testing Strategy

Unit Tests

#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
    use super::*;
    
    #[test]
    fn test_block_validation() {
        // Test with a creature that can block
        let result = validate_block(
            /* mock entities and combat system */
            Entity::from_raw(1),
            &[Entity::from_raw(2)],
            &CombatSystem::default(),
        );
        assert_eq!(result, None, "Valid block should return None");
        
        // Test with a creature that can't block
        let mut combat_system = CombatSystem::default();
        combat_system.add_block_restriction(
            Entity::from_raw(1),
            BlockRestriction::CantBlock,
        );
        
        let result = validate_block(
            Entity::from_raw(1),
            &[Entity::from_raw(2)],
            &combat_system,
        );
        assert!(result.is_some(), "Invalid block should return an error message");
    }
    
    #[test]
    fn test_flying_restriction() {
        let mut app = App::new();
        app.add_systems(Update, apply_evasion_restrictions);
        
        // Create a flying creature
        let flying_creature = app.world.spawn((
            Creature {
                abilities: vec![CreatureAbility::Flying],
                ..Default::default()
            },
        )).id();
        
        // Set up combat system
        let combat_system = CombatSystem::default();
        app.insert_resource(combat_system);
        
        // Run the system
        app.update();
        
        // Check if flying restriction was applied
        let combat_system = app.world.resource::<CombatSystem>();
        
        let has_flying_restriction = combat_system.attacker_restrictions.get(&flying_creature)
            .map_or(false, |reqs| reqs.iter().any(|req| matches!(req, AttackerRestriction::CantBeBlockedBy(BlockerCondition::NotFlyingOrReach))));
        
        assert!(has_flying_restriction, "Flying creature should have CantBeBlockedBy restriction");
    }
    
    #[test]
    fn test_multiple_blockers() {
        let mut app = App::new();
        app.add_systems(Update, handle_multiple_blockers);
        app.add_event::<GameEvent>();
        
        // Set up an attacker and multiple blockers
        let attacker = app.world.spawn(Creature::default()).id();
        let blocker1 = app.world.spawn((
            Creature::default(),
            Blocking { blocked_attackers: vec![attacker] },
        )).id();
        let blocker2 = app.world.spawn((
            Creature::default(),
            Blocking { blocked_attackers: vec![attacker] },
        )).id();
        
        // Set up combat system
        let combat_system = CombatSystem::default();
        app.insert_resource(combat_system);
        
        // Run the system
        app.update();
        
        // Check for damage assignment order event
        let mut event_reader = app.world.resource_mut::<Events<GameEvent>>();
        let mut found_event = false;
        
        for event in event_reader.iter() {
            if let GameEvent::DamageAssignmentOrderNeeded { attacker: a, blockers } = event {
                if *a == attacker && blockers.contains(&blocker1) && blockers.contains(&blocker2) {
                    found_event = true;
                    break;
                }
            }
        }
        
        assert!(found_event, "Damage assignment order event should be emitted for multiple blockers");
    }
    
    // Additional unit tests...
}
}

Integration Tests

#![allow(unused)]
fn main() {
#[cfg(test)]
mod integration_tests {
    use super::*;
    
    #[test]
    fn test_declare_blockers_workflow() {
        let mut app = App::new();
        
        // Add all relevant systems
        app.add_systems(Update, (
            declare_blockers_system,
            process_block_requirements,
            apply_evasion_restrictions,
            handle_multiple_blockers,
            process_block_triggers,
            update_creature_state_on_block,
        ));
        
        // Set up game state with attackers and defenders
        // Implementation details omitted for brevity
        
        // Simulate player declaring blockers
        app.world.resource_mut::<Events<BlockDeclarationEvent>>().send(
            BlockDeclarationEvent {
                blocker: Entity::from_raw(1),
                attackers: vec![Entity::from_raw(2)],
            }
        );
        
        // Run update to process declarations
        app.update();
        
        // Verify blockers are properly recorded and state is updated
        // Implementation details omitted for brevity
    }
    
    #[test]
    fn test_evasion_abilities_interaction() {
        let mut app = App::new();
        
        // Set up attacker with flying
        let flying_attacker = app.world.spawn((
            Creature {
                abilities: vec![CreatureAbility::Flying],
                ..Default::default()
            },
        )).id();
        
        // Set up potential blockers: one with flying, one with reach, one with neither
        let flying_blocker = app.world.spawn((
            Creature {
                abilities: vec![CreatureAbility::Flying],
                ..Default::default()
            },
        )).id();
        
        let reach_blocker = app.world.spawn((
            Creature {
                abilities: vec![CreatureAbility::Reach],
                ..Default::default()
            },
        )).id();
        
        let normal_blocker = app.world.spawn(Creature::default()).id();
        
        // Set up combat system with the attacker
        let mut combat_system = CombatSystem::default();
        combat_system.attackers.insert(flying_attacker, AttackData {
            attacker: flying_attacker,
            defender: Entity::from_raw(10), // Some player
            is_commander: false,
            requirements: Vec::new(),
            restrictions: Vec::new(),
        });
        app.insert_resource(combat_system);
        
        // Add relevant systems
        app.add_systems(Update, apply_evasion_restrictions);
        
        // Run the system
        app.update();
        
        // Try to declare blocks with each blocker
        let can_flying_block = validate_block(flying_blocker, &[flying_attacker], &app.world.resource::<CombatSystem>());
        let can_reach_block = validate_block(reach_blocker, &[flying_attacker], &app.world.resource::<CombatSystem>());
        let can_normal_block = validate_block(normal_blocker, &[flying_attacker], &app.world.resource::<CombatSystem>());
        
        // Verify results
        assert_eq!(can_flying_block, None, "Flying creature should be able to block flying attacker");
        assert_eq!(can_reach_block, None, "Reach creature should be able to block flying attacker");
        assert!(can_normal_block.is_some(), "Normal creature should not be able to block flying attacker");
    }
    
    // Additional integration tests...
}
}

UI Considerations

The UI during the Declare Blockers step needs to clearly communicate various states:

#![allow(unused)]
fn main() {
pub fn update_declare_blockers_ui(
    turn_manager: Res<TurnManager>,
    combat_system: Res<CombatSystem>,
    creature_query: Query<(Entity, &Creature, &Controllable)>,
    attacking_query: Query<Entity, With<Attacking>>,
    blocking_query: Query<(Entity, &Blocking)>,
    mut ui_state: ResMut<UiState>,
) {
    // Only run during Declare Blockers step
    if turn_manager.current_phase != Phase::Combat(CombatStep::DeclareBlockers) {
        return;
    }
    
    // Update phase display
    ui_state.current_phase_text = "Declare Blockers".to_string();
    
    // Get active player and current player with priority
    let active_player = turn_manager.get_active_player();
    let current_player = turn_manager.get_current_player();
    
    // Highlight attackers
    ui_state.attackers.clear();
    for attacker in attacking_query.iter() {
        ui_state.attackers.insert(attacker);
    }
    
    // Highlight potential blockers for current player
    ui_state.potential_blockers.clear();
    for (entity, creature, controllable) in creature_query.iter() {
        if controllable.controller == current_player && creature.can_block() {
            ui_state.potential_blockers.insert(entity);
            
            // Mark creatures that must block
            if let Some(requirements) = combat_system.block_requirements.get(&entity) {
                if requirements.iter().any(|req| matches!(req, BlockRequirement::MustBlock)) {
                    ui_state.creatures_with_requirements.insert(entity, "Must block if able".to_string());
                }
            }
        }
    }
    
    // Show current block declarations
    ui_state.current_blocks.clear();
    for (blocker, blocking) in blocking_query.iter() {
        ui_state.current_blocks.insert(blocker, blocking.blocked_attackers.clone());
    }
    
    // Highlight legal blocks
    ui_state.legal_blocks.clear();
    for attacker in attacking_query.iter() {
        let legal_blockers = creature_query
            .iter()
            .filter_map(|(entity, creature, controllable)| {
                if controllable.controller == current_player && 
                   validate_block(entity, &[attacker], &combat_system).is_none() {
                    Some(entity)
                } else {
                    None
                }
            })
            .collect::<Vec<_>>();
        
        ui_state.legal_blocks.insert(attacker, legal_blockers);
    }
}
}

Performance Considerations

  1. Efficient Block Validation: The validation of blocks should be optimized to avoid redundant checks.

  2. Caching Block Results: Once block declarations are finalized, the results can be cached for use in subsequent steps.

  3. Minimize Entity Queries: Group related queries to minimize entity access operations.

  4. Parallel Processing: For games with many blockers, processing block triggers could be done in parallel.

Conclusion

The Declare Blockers step is a critical part of the combat phase in Commander, particularly in multiplayer games where multiple players may be defending simultaneously. A robust implementation ensures that all game rules are properly enforced, including evasion abilities, protection effects, and multi-blocker scenarios. By handling block declarations, restrictions, requirements, and triggers correctly, we provide the foundation for accurate combat resolution in the following steps.

First Strike Damage

Overview

The First Strike Damage step is a conditional sub-step within the Combat Damage step that occurs when at least one creature with first strike or double strike is involved in combat. During this step, only creatures with first strike or double strike deal combat damage, while other creatures wait until the regular Combat Damage step.

This document outlines the implementation details, edge cases, and testing strategies for the First Strike Damage step in our Commander engine.

Core Concepts

First Strike Damage Flow

The First Strike Damage step follows this general flow:

  1. Check if any attacking or blocking creatures have first strike or double strike
  2. If yes, create a dedicated First Strike Damage step before the regular Combat Damage step
  3. Only creatures with first strike or double strike assign and deal damage in this step
  4. State-based actions are checked
  5. Triggers from first strike damage are put on the stack
  6. Priority passes to players in turn order
  7. Once all players pass priority and the stack is empty, the game proceeds to the regular Combat Damage step

First Strike vs. Double Strike

The key difference between first strike and double strike:

  • Creatures with first strike deal damage only during the First Strike Damage step
  • Creatures with double strike deal damage in both the First Strike Damage step and the regular Combat Damage step

Implementation Design

Data Structures

#![allow(unused)]
fn main() {
// Components for first strike and double strike abilities
#[derive(Component)]
struct FirstStrike;

#[derive(Component)]
struct DoubleStrike;

// System resource for tracking the first strike step
struct FirstStrikeDamageSystem {
    triggers_processed: bool,
}
}

First Strike Checking System

#![allow(unused)]
fn main() {
fn check_for_first_strike_step(
    mut turn_manager: ResMut<TurnManager>,
    first_strike_query: Query<Entity, Or<(With<FirstStrike>, With<DoubleStrike>)>>,
    combat_participants: Query<Entity, Or<(With<Attacking>, With<Blocking>)>>
) {
    // Only check when transitioning from Declare Blockers to Combat Damage
    if !matches!(turn_manager.current_phase, Phase::Combat(CombatStep::DeclareBlockers)) {
        return;
    }
    
    // Check if any creature in combat has first strike or double strike
    let has_first_strike = combat_participants.iter().any(|entity| {
        first_strike_query.contains(entity)
    });
    
    // If there are creatures with first strike, we'll use the FirstStrike step
    if has_first_strike {
        turn_manager.next_phase = Some(Phase::Combat(CombatStep::FirstStrike));
    } else {
        turn_manager.next_phase = Some(Phase::Combat(CombatStep::CombatDamage));
    }
}
}

First Strike Damage System

#![allow(unused)]
fn main() {
fn first_strike_damage_system(
    mut commands: Commands,
    mut combat_system: ResMut<CombatSystem>,
    turn_manager: Res<TurnManager>,
    creature_query: Query<(Entity, &Creature, &Health, Option<&FirstStrike>, Option<&DoubleStrike>)>,
    attacker_query: Query<(Entity, &Attacking)>,
    blocker_query: Query<(Entity, &Blocking)>,
    player_query: Query<(Entity, &Player, &Health)>,
    mut commander_damage: Query<&mut CommanderDamage>,
    // Other system parameters
) {
    // Only run during first strike damage step
    if !matches!(turn_manager.current_phase, Phase::Combat(CombatStep::FirstStrike)) {
        return;
    }
    
    // Find creatures with first strike or double strike that are in combat
    let first_strike_creatures = creature_query
        .iter()
        .filter(|(entity, _, _, first_strike, double_strike)| {
            (first_strike.is_some() || double_strike.is_some()) &&
            (attacker_query.contains(*entity) || blocker_query.contains(*entity))
        })
        .collect::<Vec<_>>();
        
    // Assign and deal damage
    for (entity, creature, _, _, _) in first_strike_creatures {
        // Determine damage amount and recipients
        let power = creature.power;
        
        if let Ok((_, attacking)) = attacker_query.get(entity) {
            // Attacking creature deals damage
            assign_attacker_damage(
                entity, 
                power, 
                attacking.defending, 
                &blocker_query, 
                &mut combat_system
            );
        } else if let Ok((_, blocking)) = blocker_query.get(entity) {
            // Blocking creature deals damage
            assign_blocker_damage(
                entity, 
                power, 
                &blocking.blocked_attackers, 
                &mut combat_system
            );
        }
    }
    
    // Deal all assigned damage simultaneously
    process_damage_assignments(&mut commands, &combat_system, &mut commander_damage);
    
    // Check for state-based actions
    check_state_based_actions(&mut commands, &player_query, &creature_query);
    
    // Record that first strike damage has been processed
    combat_system.first_strike_round_completed = true;
}
}

Special Cases and Edge Scenarios

Gaining/Losing First Strike During Combat

Creatures might gain or lose first strike abilities during combat:

#![allow(unused)]
fn main() {
fn handle_first_strike_changes(
    mut first_strike_system: ResMut<FirstStrikeDamageSystem>,
    turn_manager: Res<TurnManager>,
    added_first_strike: Query<Entity, Added<FirstStrike>>,
    removed_first_strike: Query<Entity, Without<FirstStrike>>,
    combat_participants: Query<Entity, Or<(With<Attacking>, With<Blocking>)>>,
    was_in_combat: Local<HashSet<Entity>>
) {
    // Check if any creature in combat gained or lost first strike
    let gained_first_strike = added_first_strike.iter()
        .filter(|&entity| combat_participants.contains(entity))
        .count() > 0;
        
    let lost_first_strike = removed_first_strike.iter()
        .filter(|&entity| was_in_combat.contains(&entity))
        .count() > 0;
        
    // If changes occurred during declare blockers step, we might need to adjust
    // whether a first strike step will occur
    if matches!(turn_manager.current_phase, Phase::Combat(CombatStep::DeclareBlockers)) {
        if gained_first_strike {
            first_strike_system.needed = true;
        }
    }
    
    // Update our tracking of combat participants
    was_in_combat.clear();
    for entity in combat_participants.iter() {
        was_in_combat.insert(entity);
    }
}
}

Removing Creatures During First Strike

If a creature is destroyed by first strike damage, it doesn't deal regular combat damage:

#![allow(unused)]
fn main() {
fn track_destroyed_by_first_strike(
    combat_system: Res<CombatSystem>,
    mut destroyed_entities: ResMut<DestroyedEntities>,
    damage_events: Query<&DamageEvent>,
    creature_query: Query<&Health>
) {
    // Only track during first strike damage
    if !combat_system.first_strike_round_completed {
        return;
    }
    
    // Find creatures that received lethal damage during first strike
    for damage_event in damage_events.iter() {
        if let Ok(health) = creature_query.get(damage_event.target) {
            if health.current <= 0 {
                destroyed_entities.insert(damage_event.target);
            }
        }
    }
}
}

Fast Effect Windows

Players get priority after first strike damage, allowing them to cast spells before regular damage:

#![allow(unused)]
fn main() {
fn handle_first_strike_priority(
    mut turn_manager: ResMut<TurnManager>,
    stack: Res<Stack>,
    combat_system: Res<CombatSystem>
) {
    // Only handle during first strike step
    if !matches!(turn_manager.current_phase, Phase::Combat(CombatStep::FirstStrike)) {
        return;
    }
    
    // If first strike damage has been dealt and the stack is empty,
    // we can proceed to regular combat damage
    if combat_system.first_strike_round_completed && stack.is_empty() {
        turn_manager.advance_phase();
    }
}
}

Testing Strategy

Unit Tests

#![allow(unused)]
fn main() {
#[test]
fn test_first_strike_damage() {
    // Set up test world
    let mut app = App::new();
    app.add_plugins(MinimalPlugins)
       .add_systems(Update, first_strike_damage_system);
       
    // Create attacker with first strike
    let attacker = app.world.spawn((
        Creature { power: 3, toughness: 3 },
        Attacking { defending: player_entity },
        FirstStrike,
    )).id();
    
    // Create blocker without first strike
    let blocker = app.world.spawn((
        Creature { power: 2, toughness: 2 },
        Blocking { blocked_attackers: vec![attacker] },
        Health { current: 2, maximum: 2 },
    )).id();
    
    // Process first strike damage
    app.world.resource_mut::<TurnManager>().current_phase = Phase::Combat(CombatStep::FirstStrike);
    app.update();
    
    // Verify blocker received damage and was destroyed
    let health = app.world.get::<Health>(blocker).unwrap();
    assert_eq!(health.current, 0); // Received 3 damage from first strike attacker
    
    // Verify no damage was dealt to attacker yet (since blocker doesn't have first strike)
    let attacker_health = app.world.get::<Health>(attacker).unwrap();
    assert_eq!(attacker_health.current, attacker_health.maximum);
}

#[test]
fn test_double_strike_damage() {
    // Test that double strike creatures deal damage in both steps
    // ...
}

#[test]
fn test_first_strike_detection() {
    // Test that the system correctly detects when a first strike step is needed
    // ...
}
}

Integration Tests

#![allow(unused)]
fn main() {
#[test]
fn test_first_strike_vs_regular_damage() {
    // Test complete sequence with both first strike and regular damage
    // ...
}

#[test]
fn test_gaining_first_strike_mid_combat() {
    // Test gaining first strike during combat
    // ...
}

#[test]
fn test_losing_first_strike_mid_combat() {
    // Test losing first strike during combat
    // ...
}
}

Edge Case Tests

#![allow(unused)]
fn main() {
#[test]
fn test_first_strike_with_triggered_abilities() {
    // Test interaction between first strike damage and triggered abilities
    // ...
}

#[test]
fn test_first_strike_with_damage_prevention() {
    // Test first strike damage with damage prevention effects
    // ...
}

#[test]
fn test_first_strike_with_damage_redirection() {
    // Test first strike damage with redirection effects
    // ...
}
}

Performance Considerations

  1. Conditional Step Creation: Only create the First Strike Damage step when needed.

  2. Efficient Entity Filtering: Optimize filtering of entities with first strike/double strike.

  3. Damage Assignment Optimization: Reuse damage assignment logic between first strike and regular damage steps.

  4. State Tracking: Efficiently track state between first strike and regular damage steps.

Conclusion

The First Strike Damage step is a specialized combat sub-step that adds strategic depth to the combat system. By correctly implementing first strike and double strike mechanics, we ensure that these powerful combat abilities function as expected according to the Magic: The Gathering rules. The implementation must handle all edge cases, including mid-combat changes to first strike status, while maintaining good performance and seamless integration with the rest of the combat system.

Combat Damage

Overview

The Combat Damage step is the critical phase of combat where damage is assigned and dealt between attacking and blocking creatures, as well as to players and planeswalkers. In Commander, this step has additional significance due to the presence of Commander damage tracking and the multiplayer nature of the format.

This document outlines the implementation details, edge cases, and testing strategies for the Combat Damage step in our Commander engine.

Core Concepts

Combat Damage Flow

The Combat Damage step follows this general flow:

  1. First, the active player assigns combat damage from their attacking creatures
  2. Then, each defending player assigns combat damage from their blocking creatures
  3. All damage is dealt simultaneously
  4. State-based actions are checked, including:
    • Creatures with lethal damage are destroyed
    • Players with 0 or less life lose the game
    • Players with 21+ commander damage from a single commander lose the game
  5. Combat damage triggers are placed on the stack
  6. Priority passes to players in turn order

First Strike and Double Strike

Combat damage may occur in two sub-steps when creatures with first strike or double strike are involved:

  1. First Strike Combat Damage - Damage from creatures with first strike or double strike
  2. Regular Combat Damage - Damage from all other creatures and second instance of damage from double strike creatures

Implementation Design

Data Structures

#![allow(unused)]
fn main() {
// Represents the damage assignment for a specific attacker or blocker
struct DamageAssignment {
    source: Entity,         // The entity dealing damage
    target: Entity,         // The entity receiving damage
    amount: u32,            // Amount of damage
    is_commander_damage: bool, // Whether this is commander damage
    is_combat_damage: bool, // Always true for combat damage step
}

// Component for tracking assigned damage
#[derive(Component)]
struct AssignedDamage {
    assignments: Vec<DamageAssignment>,
}

// System resource for managing the combat damage step
struct CombatDamageSystem {
    first_strike_round_completed: bool,
    damage_assignments: HashMap<Entity, Vec<DamageAssignment>>,
}
}

Combat Damage System

#![allow(unused)]
fn main() {
fn combat_damage_system(
    mut commands: Commands,
    mut combat_system: ResMut<CombatSystem>,
    turn_manager: Res<TurnManager>,
    creature_query: Query<(Entity, &Creature, &Health, Option<&FirstStrike>, Option<&DoubleStrike>)>,
    attacker_query: Query<(Entity, &Attacking)>,
    blocker_query: Query<(Entity, &Blocking)>,
    player_query: Query<(Entity, &Player, &Health)>,
    mut commander_damage: Query<&mut CommanderDamage>,
    // Other system parameters
) {
    // Check if we need to process first strike damage
    let current_step = match turn_manager.current_phase {
        Phase::Combat(CombatStep::FirstStrike) => CombatStep::FirstStrike,
        Phase::Combat(CombatStep::CombatDamage) => CombatStep::CombatDamage,
        _ => return, // Not in a combat damage step
    };
    
    // Determine which creatures deal damage in this step
    let (first_strike_creatures, regular_creatures) = creature_query
        .iter()
        .partition(|(_, _, _, first_strike, double_strike)| 
            first_strike.is_some() || double_strike.is_some()
        );
        
    // Process creatures that deal damage in this step
    let applicable_creatures = match current_step {
        CombatStep::FirstStrike => &first_strike_creatures,
        CombatStep::CombatDamage => {
            // In regular combat damage, double strike creatures deal damage again,
            // and creatures without first/double strike deal damage for the first time
            let mut creatures = Vec::new();
            creatures.extend(regular_creatures);
            creatures.extend(
                first_strike_creatures
                    .iter()
                    .filter(|(_, _, _, _, double_strike)| double_strike.is_some())
            );
            &creatures
        }
        _ => return,
    };
    
    // Assign and deal damage
    for (entity, creature, health, _, _) in applicable_creatures {
        // Determine damage amount and recipients
        let power = creature.power;
        
        if let Ok((_, attacking)) = attacker_query.get(*entity) {
            // Attacking creature deals damage
            assign_attacker_damage(
                *entity, 
                power, 
                attacking.defending, 
                &blocker_query, 
                &mut combat_system
            );
        } else if let Ok((_, blocking)) = blocker_query.get(*entity) {
            // Blocking creature deals damage
            assign_blocker_damage(
                *entity, 
                power, 
                &blocking.blocked_attackers, 
                &mut combat_system
            );
        }
    }
    
    // Deal all assigned damage simultaneously
    process_damage_assignments(&mut commands, &combat_system, &mut commander_damage);
    
    // Check for state-based actions
    check_state_based_actions(&mut commands, &player_query, &creature_query);
    
    // If this was the first strike step, we'll continue to regular damage
    // If this was regular combat damage, we'll proceed to end of combat
    if current_step == CombatStep::FirstStrike {
        combat_system.first_strike_round_completed = true;
    } else {
        combat_system.first_strike_round_completed = false;
        // Clean up damage assignments
        combat_system.damage_assignments.clear();
    }
}
}

Damage Assignment Functions

#![allow(unused)]
fn main() {
fn assign_attacker_damage(
    attacker: Entity,
    power: u32,
    defending: Entity,
    blocker_query: &Query<(Entity, &Blocking)>,
    combat_system: &mut ResMut<CombatSystem>
) {
    // Find blockers for this attacker
    let blockers = blocker_query
        .iter()
        .filter(|(_, blocking)| blocking.blocked_attackers.contains(&attacker))
        .map(|(entity, _)| entity)
        .collect::<Vec<_>>();
    
    if blockers.is_empty() {
        // Unblocked attacker - deal damage to player or planeswalker
        combat_system.damage_assignments.entry(attacker).or_default().push(
            DamageAssignment {
                source: attacker,
                target: defending,
                amount: power,
                is_commander_damage: true, // Check if attacker is a commander
                is_combat_damage: true,
            }
        );
    } else {
        // Attacker is blocked - distribute damage among blockers
        // (In a real implementation, this would handle player choices for damage assignment)
        
        // Simplified version: divide damage evenly
        let damage_per_blocker = power / blockers.len() as u32;
        let remainder = power % blockers.len() as u32;
        
        for (i, blocker) in blockers.iter().enumerate() {
            let damage = damage_per_blocker + if i < remainder as usize { 1 } else { 0 };
            
            combat_system.damage_assignments.entry(attacker).or_default().push(
                DamageAssignment {
                    source: attacker,
                    target: *blocker,
                    amount: damage,
                    is_commander_damage: false, // Commander damage only applies to players
                    is_combat_damage: true,
                }
            );
        }
    }
}

fn assign_blocker_damage(
    blocker: Entity,
    power: u32,
    blocked_attackers: &[Entity],
    combat_system: &mut ResMut<CombatSystem>
) {
    // Simplified version: divide damage evenly among attackers
    // (In a real implementation, this would handle player choices for damage assignment)
    
    let damage_per_attacker = power / blocked_attackers.len() as u32;
    let remainder = power % blocked_attackers.len() as u32;
    
    for (i, attacker) in blocked_attackers.iter().enumerate() {
        let damage = damage_per_attacker + if i < remainder as usize { 1 } else { 0 };
        
        combat_system.damage_assignments.entry(blocker).or_default().push(
            DamageAssignment {
                source: blocker,
                target: *attacker,
                amount: damage,
                is_commander_damage: false,
                is_combat_damage: true,
            }
        );
    }
}

fn process_damage_assignments(
    commands: &mut Commands,
    combat_system: &CombatSystem,
    commander_damage: &mut Query<&mut CommanderDamage>
) {
    // Process all damage assignments
    for (source, assignments) in &combat_system.damage_assignments {
        for assignment in assignments {
            // Deal damage to target
            commands.entity(assignment.target).insert(
                DamageReceived {
                    amount: assignment.amount,
                    source: assignment.source,
                    is_combat_damage: true,
                }
            );
            
            // If this is commander damage, update commander damage tracker
            if assignment.is_commander_damage {
                if let Ok(mut cmd_damage) = commander_damage.get_mut(assignment.target) {
                    cmd_damage.add_damage(assignment.source, assignment.amount);
                }
            }
            
            // Add damage event for triggers
            commands.spawn(DamageEvent {
                source: assignment.source,
                target: assignment.target,
                amount: assignment.amount,
                is_combat_damage: true,
            });
        }
    }
}
}

Special Cases and Edge Scenarios

Trample

When an attacking creature with trample is blocked, excess damage is dealt to the defending player:

#![allow(unused)]
fn main() {
fn assign_attacker_damage_with_trample(
    attacker: Entity,
    power: u32,
    defending: Entity,
    blocker_query: &Query<(Entity, &Blocking, &Health)>,
    trample_query: &Query<&Trample>,
    combat_system: &mut ResMut<CombatSystem>
) {
    // Find blockers for this attacker
    let blockers = blocker_query
        .iter()
        .filter(|(_, blocking, _)| blocking.blocked_attackers.contains(&attacker))
        .collect::<Vec<_>>();
    
    if blockers.is_empty() {
        // Handle unblocked attacker as normal
        // ...
    } else if trample_query.get(attacker).is_ok() {
        // Attacker has trample - assign lethal damage to each blocker
        let mut remaining_damage = power;
        
        for (blocker, _, health) in &blockers {
            let lethal_damage = health.current.min(remaining_damage);
            remaining_damage -= lethal_damage;
            
            // Assign lethal damage to blocker
            combat_system.damage_assignments.entry(attacker).or_default().push(
                DamageAssignment {
                    source: attacker,
                    target: *blocker,
                    amount: lethal_damage,
                    is_commander_damage: false,
                    is_combat_damage: true,
                }
            );
        }
        
        // Assign remaining damage to player
        if remaining_damage > 0 {
            combat_system.damage_assignments.entry(attacker).or_default().push(
                DamageAssignment {
                    source: attacker,
                    target: defending,
                    amount: remaining_damage,
                    is_commander_damage: true, // Check if attacker is a commander
                    is_combat_damage: true,
                }
            );
        }
    } else {
        // Handle normal blocked creature (no trample)
        // ...
    }
}
}

Deathtouch

Creatures with deathtouch need to assign only 1 damage to be considered lethal:

#![allow(unused)]
fn main() {
fn is_lethal_damage(
    amount: u32,
    source: Entity,
    deathtouch_query: &Query<&Deathtouch>
) -> bool {
    if amount > 0 && deathtouch_query.get(source).is_ok() {
        return true;
    }
    amount > 0
}
}

Damage Prevention and Replacement

Damage can be prevented or modified by various effects:

#![allow(unused)]
fn main() {
fn apply_damage_prevention_effects(
    assignment: &mut DamageAssignment,
    prevention_query: &Query<&PreventDamage>
) {
    if let Ok(prevent) = prevention_query.get(assignment.target) {
        let prevented = prevent.amount.min(assignment.amount);
        assignment.amount -= prevented;
    }
}
}

Damage Redirection

Some effects can redirect damage to different entities:

#![allow(unused)]
fn main() {
fn apply_damage_redirection(
    assignment: &mut DamageAssignment,
    redirection_query: &Query<&RedirectDamage>
) {
    if let Ok(redirect) = redirection_query.get(assignment.target) {
        // Change the target of the damage
        assignment.target = redirect.new_target;
        // Adjust commander damage flag if needed
        assignment.is_commander_damage = is_commander_entity(redirect.new_target);
    }
}
}

Testing Strategy

Unit Tests

#![allow(unused)]
fn main() {
#[test]
fn test_basic_combat_damage() {
    // Set up test world
    let mut app = App::new();
    app.add_plugins(MinimalPlugins)
       .add_systems(Update, combat_damage_system);
       
    // Create attacker and defender
    let attacker = app.world.spawn((
        Creature { power: 3, toughness: 3, },
        Attacking { defending: player_entity },
    )).id();
    
    let defender = app.world.spawn((
        Player {},
        Health { current: 20, maximum: 20 },
        CommanderDamage::default(),
    )).id();
    
    // Process combat damage
    app.update();
    
    // Verify damage was dealt
    let health = app.world.get::<Health>(defender).unwrap();
    assert_eq!(health.current, 17);
}

#[test]
fn test_first_strike_damage() {
    // Set up test world with first strike creatures
    // ...
}

#[test]
fn test_blocked_creature_damage() {
    // Test damage assignment with blockers
    // ...
}

#[test]
fn test_commander_damage_tracking() {
    // Test commander damage accumulation
    // ...
}
}

Integration Tests

#![allow(unused)]
fn main() {
#[test]
fn test_full_combat_sequence() {
    // Set up a complete combat sequence with attackers, blockers,
    // and damage calculation
    // ...
}

#[test]
fn test_combat_with_damage_prevention() {
    // Test combat with damage prevention effects
    // ...
}

#[test]
fn test_combat_with_replacement_effects() {
    // Test combat with damage replacement effects
    // ...
}
}

Edge Case Tests

#![allow(unused)]
fn main() {
#[test]
fn test_damage_to_indestructible() {
    // Test damage to indestructible creatures
    // ...
}

#[test]
fn test_damage_with_deathtouch_and_trample() {
    // Test the interaction of deathtouch and trample
    // ...
}

#[test]
fn test_damage_redirection() {
    // Test redirection of combat damage
    // ...
}
}

Performance Considerations

  1. Batch Damage Processing: Process all damage assignments simultaneously for better performance.

  2. Damage Event Optimization: Use a more efficient event system for damage events to avoid spawning entities.

  3. Damage Assignment Caching: Cache damage assignments to avoid recalculating them.

  4. Parallel Processing: Use parallel processing for damage assignment when appropriate.

Conclusion

The Combat Damage step is a complex but crucial part of the Commander game engine. Proper implementation ensures fair and accurate damage calculation, especially for tracking commander damage which is a key victory condition in the format. By handling special abilities like first strike, double strike, trample, and deathtouch correctly, we create a robust combat system that faithfully represents the Magic: The Gathering rules while maintaining good performance.

End of Combat

Overview

The End of Combat step is the final phase of the combat sequence where "at end of combat" triggered abilities are put on the stack and resolved. This step also includes important cleanup activities that return the game state to normal after combat concludes. In Commander, this step is particularly important for handling multiplayer-specific combat interactions and resetting the combat state.

This document outlines the implementation details, edge cases, and testing strategies for the End of Combat step in our Commander engine.

Core Concepts

End of Combat Flow

The End of Combat step follows this general sequence:

  1. "At end of combat" triggered abilities are put on the stack
  2. Priority is passed to players in turn order
  3. Once all players pass priority and the stack is empty, combat cleanup occurs:
    • "Until end of combat" effects end
    • Combat-specific statuses are removed
    • The game advances to the Postcombat Main Phase

Combat Cleanup

After the End of Combat step is complete, several important cleanup tasks must occur:

  1. Remove all attacking and blocking statuses from creatures
  2. End "until end of combat" effects
  3. Clear combat damage tracking from the current turn
  4. Reset any combat-specific flags or state

Implementation Design

Data Structures

#![allow(unused)]
fn main() {
// Component for tracking "until end of combat" effects
#[derive(Component)]
struct UntilEndOfCombat {
    // Any data needed for the effect
}

// System resource for managing the end of combat step
struct EndOfCombatSystem {
    triggers_processed: bool,
}
}

End of Combat System

#![allow(unused)]
fn main() {
fn end_of_combat_system(
    mut commands: Commands,
    mut combat_system: ResMut<CombatSystem>,
    turn_manager: Res<TurnManager>,
    attacker_query: Query<Entity, With<Attacking>>,
    blocker_query: Query<Entity, With<Blocking>>,
    end_of_combat_effects: Query<Entity, With<UntilEndOfCombat>>,
    end_of_combat_triggers: Query<(Entity, &EndOfCombatTrigger)>,
    // Other system parameters
) {
    // Only run during end of combat step
    if !matches!(turn_manager.current_phase, Phase::Combat(CombatStep::End)) {
        return;
    }
    
    // Process "at end of combat" triggers if not already processed
    if !combat_system.end_of_combat.triggers_processed {
        for (entity, trigger) in end_of_combat_triggers.iter() {
            // Create trigger event
            commands.spawn(TriggerEvent {
                source: entity,
                trigger_type: TriggerType::EndOfCombat,
            });
        }
        
        combat_system.end_of_combat.triggers_processed = true;
        return; // Exit to allow triggers to be processed
    }
    
    // If we've processed triggers and the stack is empty, perform combat cleanup
    if combat_system.end_of_combat.triggers_processed && turn_manager.stack_is_empty() {
        // Remove attacking status from all attackers
        for entity in attacker_query.iter() {
            commands.entity(entity).remove::<Attacking>();
        }
        
        // Remove blocking status from all blockers
        for entity in blocker_query.iter() {
            commands.entity(entity).remove::<Blocking>();
        }
        
        // End "until end of combat" effects
        for entity in end_of_combat_effects.iter() {
            commands.entity(entity).remove::<UntilEndOfCombat>();
        }
        
        // Reset combat system state
        combat_system.reset();
        
        // Combat complete, ready to advance to postcombat main phase
    }
}
}

Combat System Reset

#![allow(unused)]
fn main() {
impl CombatSystem {
    pub fn reset(&mut self) {
        self.attackers.clear();
        self.blockers.clear();
        self.damage_assignments.clear();
        self.first_strike_round_completed = false;
        self.begin_combat.triggers_processed = false;
        self.declare_attackers.triggers_processed = false;
        self.declare_blockers.triggers_processed = false;
        self.end_of_combat.triggers_processed = false;
    }
}
}

Special Cases and Edge Scenarios

Delayed End of Combat Triggers

Some effects create delayed triggered abilities that trigger at the end of combat:

#![allow(unused)]
fn main() {
fn create_delayed_end_of_combat_trigger(
    commands: &mut Commands,
    source: Entity,
    effect: impl Fn(&mut Commands) + Send + Sync + 'static
) {
    commands.spawn((
        DelayedTrigger {
            source,
            trigger_type: TriggerType::EndOfCombat,
            effect: Box::new(effect),
        },
    ));
}
}

Regeneration Shield Cleanup

Regeneration shields that were used during combat should be removed:

#![allow(unused)]
fn main() {
fn cleanup_regeneration_shields(
    mut commands: Commands,
    regeneration_query: Query<(Entity, &RegenerationShield)>
) {
    for (entity, shield) in regeneration_query.iter() {
        if shield.used {
            commands.entity(entity).remove::<RegenerationShield>();
        }
    }
}
}

"Until End of Combat" Effects

Effects that last until end of combat need special handling:

#![allow(unused)]
fn main() {
fn apply_until_end_of_combat_effect(
    commands: &mut Commands,
    target: Entity,
    effect_data: EffectData
) {
    // Apply the effect
    commands.entity(target).insert(effect_data.component);
    
    // Mark it to be removed at end of combat
    commands.entity(target).insert(UntilEndOfCombat {
        // Any necessary data
    });
}
}

Phasing During End of Combat

Creatures that phase out during combat might need special handling during end of combat:

#![allow(unused)]
fn main() {
fn handle_phased_combat_participants(
    mut commands: Commands,
    phased_attackers: Query<Entity, (With<Attacking>, With<PhasedOut>)>,
    phased_blockers: Query<Entity, (With<Blocking>, With<PhasedOut>)>
) {
    // Remove combat status from phased creatures too
    for entity in phased_attackers.iter() {
        commands.entity(entity).remove::<Attacking>();
    }
    
    for entity in phased_blockers.iter() {
        commands.entity(entity).remove::<Blocking>();
    }
}
}

Testing Strategy

Unit Tests

#![allow(unused)]
fn main() {
#[test]
fn test_basic_end_of_combat_cleanup() {
    // Set up test world
    let mut app = App::new();
    app.add_plugins(MinimalPlugins)
       .add_systems(Update, end_of_combat_system);
       
    // Create attackers and blockers
    let attacker = app.world.spawn((
        Creature { power: 3, toughness: 3 },
        Attacking { defending: player_entity },
    )).id();
    
    let blocker = app.world.spawn((
        Creature { power: 2, toughness: 2 },
        Blocking { blocked_attackers: vec![attacker] },
    )).id();
    
    // Simulate end of combat step
    app.world.resource_mut::<TurnManager>().current_phase = Phase::Combat(CombatStep::End);
    app.world.resource_mut::<CombatSystem>().end_of_combat.triggers_processed = true;
    
    // Process end of combat
    app.update();
    
    // Verify cleanup
    assert!(!app.world.entity(attacker).contains::<Attacking>());
    assert!(!app.world.entity(blocker).contains::<Blocking>());
}

#[test]
fn test_end_of_combat_triggers() {
    // Test triggers that happen at end of combat
    // ...
}

#[test]
fn test_until_end_of_combat_effects() {
    // Test that effects with "until end of combat" duration are removed
    // ...
}
}

Integration Tests

#![allow(unused)]
fn main() {
#[test]
fn test_full_combat_sequence_with_end_phase() {
    // Test a complete combat sequence from beginning to end
    // ...
}

#[test]
fn test_end_of_combat_with_replacement_effects() {
    // Test end of combat with replacement effects that modify cleanup
    // ...
}
}

Edge Case Tests

#![allow(unused)]
fn main() {
#[test]
fn test_end_of_combat_with_phased_creatures() {
    // Test end of combat with phased creatures
    // ...
}

#[test]
fn test_end_of_combat_when_player_loses() {
    // Test what happens if a player loses during end of combat
    // ...
}

#[test]
fn test_end_of_combat_with_delayed_triggers() {
    // Test delayed triggers that happen at end of combat
    // ...
}
}

Performance Considerations

  1. Batch Removal Operations: Group similar removal operations together for better performance.

  2. Minimize Query Iterations: Structure queries to minimize iterations over entities.

  3. State Reset Optimization: Efficiently reset the combat state without unnecessary operations.

  4. Effect Tracking: Use an efficient system for tracking and removing "until end of combat" effects.

Conclusion

The End of Combat step is a critical transition point in the Commander game flow. Proper implementation ensures that combat-related statuses and effects are appropriately cleaned up, and the game state is correctly prepared for the next phase. By handling all "at end of combat" triggers and cleanup operations correctly, we create a robust and reliable combat system that maintains game state integrity while supporting all the complex interactions present in the Commander format.

Combat Abilities

Overview

Magic: The Gathering includes numerous abilities that specifically interact with combat. In Commander, these abilities gain additional complexity due to the multiplayer nature of the format and the special rules surrounding commander damage. This document details how combat-specific abilities are implemented and tested in our game engine.

Combat Keywords

Combat keywords are implemented through a combination of component flags and systems that check for these keywords during the appropriate combat steps.

#![allow(unused)]
fn main() {
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Keyword {
    // Combat-specific keywords
    FirstStrike,
    DoubleStrike,
    Deathtouch,
    Trample,
    Vigilance,
    Menace,
    Flying,
    Reach,
    Indestructible,
    Lifelink,
    // Other keywords...
}

// Component that stores a creature's abilities
#[derive(Component, Clone)]
pub struct Abilities(pub Vec<Ability>);

// Different ability types
#[derive(Debug, Clone)]
pub enum Ability {
    Keyword(Keyword),
    Triggered(Trigger, Effect),
    Activated(ActivationCost, Effect),
    Static(StaticEffect),
    // Other ability types...
}

// Implement utility functions for checking abilities
impl Creature {
    pub fn has_ability(&self, ability: Ability) -> bool {
        // Implementation details omitted for brevity
        false
    }
    
    pub fn has_keyword(&self, keyword: Keyword) -> bool {
        // Check if the creature has the specified keyword
        if let Ok(abilities) = abilities_query.get(self.entity) {
            abilities.0.iter().any(|ability| {
                if let Ability::Keyword(kw) = ability {
                    *kw == keyword
                } else {
                    false
                }
            })
        } else {
            false
        }
    }
}
}

Keyword Implementation Systems

Each combat keyword has a dedicated system that implements its effect:

First Strike and Double Strike

#![allow(unused)]
fn main() {
pub fn first_strike_damage_system(
    mut combat_system: ResMut<CombatSystem>,
    turn_manager: Res<TurnManager>,
    creature_query: Query<(Entity, &Creature)>,
    mut game_events: EventWriter<GameEvent>,
) {
    // Only run during the first strike damage step
    if turn_manager.current_phase != Phase::Combat(CombatStep::FirstStrike) {
        return;
    }
    
    // Get all attackers with first strike or double strike
    let first_strike_attackers = combat_system.attackers
        .iter()
        .filter_map(|(attacker, attack_data)| {
            if let Ok((entity, creature)) = creature_query.get(*attacker) {
                if creature.has_keyword(Keyword::FirstStrike) || 
                   creature.has_keyword(Keyword::DoubleStrike) {
                    return Some((attacker, attack_data));
                }
            }
            None
        })
        .collect::<Vec<_>>();
    
    // Get all blockers with first strike or double strike
    let first_strike_blockers = combat_system.blockers
        .iter()
        .filter_map(|(blocker, block_data)| {
            if let Ok((entity, creature)) = creature_query.get(*blocker) {
                if creature.has_keyword(Keyword::FirstStrike) || 
                   creature.has_keyword(Keyword::DoubleStrike) {
                    return Some((blocker, block_data));
                }
            }
            None
        })
        .collect::<Vec<_>>();
    
    // Process first strike combat damage
    // Implementation details omitted for brevity
    
    // Emit event for first strike damage
    game_events.send(GameEvent::FirstStrikeDamageDealt {
        attackers: first_strike_attackers.len(),
        blockers: first_strike_blockers.len(),
    });
}
}

Deathtouch

#![allow(unused)]
fn main() {
pub fn apply_deathtouch_system(
    combat_system: Res<CombatSystem>,
    creature_query: Query<(Entity, &Creature)>,
    mut commands: Commands,
) {
    // Find all creatures with deathtouch that dealt damage
    let deathtouch_creatures = combat_system.combat_history
        .iter()
        .filter_map(|event| {
            if let CombatEvent::DamageDealt { source, target, amount, .. } = event {
                if *amount > 0 {
                    if let Ok((entity, creature)) = creature_query.get(*source) {
                        if creature.has_keyword(Keyword::Deathtouch) {
                            return Some((source, target));
                        }
                    }
                }
            }
            None
        })
        .collect::<Vec<_>>();
    
    // Apply destroy effect to creatures that were damaged by deathtouch
    for (source, target) in deathtouch_creatures {
        // Only apply to creatures, not players or planeswalkers
        if creature_query.contains(*target) {
            // Mark creature as destroyed
            commands.entity(*target).insert(Destroyed {
                source: *source,
                reason: DestructionReason::Deathtouch,
            });
        }
    }
}
}

Trample

#![allow(unused)]
fn main() {
pub fn apply_trample_damage_system(
    mut combat_system: ResMut<CombatSystem>,
    creature_query: Query<(Entity, &Creature)>,
    blocker_toughness_query: Query<&Creature>,
    player_query: Query<(Entity, &mut Player)>,
    mut game_events: EventWriter<GameEvent>,
) {
    // Find all creatures with trample that are being blocked
    for (attacker, attack_data) in combat_system.attackers.iter() {
        // Only process if the attacker has trample
        if let Ok((entity, creature)) = creature_query.get(*attacker) {
            if !creature.has_keyword(Keyword::Trample) {
                continue;
            }
            
            // Check if this attacker is blocked
            let blockers: Vec<Entity> = combat_system.blockers.iter()
                .filter_map(|(blocker, block_data)| {
                    if block_data.blocked_attackers.contains(attacker) {
                        Some(*blocker)
                    } else {
                        None
                    }
                })
                .collect();
            
            // Only apply trample if the creature is blocked
            if !blockers.is_empty() {
                // Calculate total blocker toughness
                let total_blocker_toughness: u32 = blockers.iter()
                    .filter_map(|blocker| {
                        blocker_toughness_query.get(*blocker).ok().map(|creature| creature.toughness)
                    })
                    .sum();
                
                // Calculate trample damage (attacker power - total blocker toughness)
                let trample_damage = creature.power.saturating_sub(total_blocker_toughness);
                
                // Apply trample damage to defending player if there's excess damage
                if trample_damage > 0 {
                    // Only apply to players, not planeswalkers
                    for (player_entity, mut player) in player_query.iter_mut() {
                        if player_entity == attack_data.defender {
                            // Apply the damage
                            player.life_total -= trample_damage as i32;
                            
                            // Record the damage event
                            combat_system.combat_history.push_back(CombatEvent::DamageDealt {
                                source: *attacker,
                                target: player_entity,
                                amount: trample_damage,
                                is_commander_damage: attack_data.is_commander,
                            });
                            
                            // Check if player lost from trample damage
                            if player.life_total <= 0 {
                                game_events.send(GameEvent::PlayerLost {
                                    player: player_entity,
                                    reason: LossReason::ZeroLife,
                                });
                            }
                            
                            break;
                        }
                    }
                }
            }
        }
    }
}
}

Flying and Reach

#![allow(unused)]
fn main() {
pub fn validate_blocks_flying_system(
    mut combat_system: ResMut<CombatSystem>,
    creature_query: Query<(Entity, &Creature)>,
    mut block_events: EventWriter<BlockValidationEvent>,
) {
    // Check for illegal blocks involving flying creatures
    let mut illegal_blocks = Vec::new();
    
    for (blocker, block_data) in combat_system.blockers.iter() {
        // Get blocker details
        if let Ok((blocker_entity, blocker_creature)) = creature_query.get(*blocker) {
            // Check if blocker has reach or flying
            let can_block_flying = blocker_creature.has_keyword(Keyword::Flying) || 
                                  blocker_creature.has_keyword(Keyword::Reach);
            
            // Check all attackers this creature is blocking
            for attacker in &block_data.blocked_attackers {
                if let Ok((attacker_entity, attacker_creature)) = creature_query.get(*attacker) {
                    // If attacker has flying and blocker can't block flying, this is illegal
                    if attacker_creature.has_keyword(Keyword::Flying) && !can_block_flying {
                        illegal_blocks.push((*blocker, *attacker));
                    }
                }
            }
        }
    }
    
    // Remove illegal blocks
    for (blocker, attacker) in illegal_blocks {
        // Remove the block relationship
        if let Some(mut block_data) = combat_system.blockers.get_mut(&blocker) {
            block_data.blocked_attackers.retain(|a| *a != attacker);
            
            // If no more attackers, remove blocker entry
            if block_data.blocked_attackers.is_empty() {
                combat_system.blockers.remove(&blocker);
            }
        }
        
        // Emit event for illegal block
        block_events.send(BlockValidationEvent::IllegalBlock {
            blocker,
            attacker,
            reason: "Can't block flying".to_string(),
        });
    }
}
}

Menace

#![allow(unused)]
fn main() {
pub fn validate_blocks_menace_system(
    mut combat_system: ResMut<CombatSystem>,
    creature_query: Query<(Entity, &Creature)>,
    mut block_events: EventWriter<BlockValidationEvent>,
) {
    // Find attackers with menace that are being blocked by only one creature
    let menace_violations = combat_system.attackers
        .iter()
        .filter_map(|(attacker, attack_data)| {
            // Check if attacker has menace
            if let Ok((entity, creature)) = creature_query.get(*attacker) {
                if creature.has_keyword(Keyword::Menace) {
                    // Count blockers for this attacker
                    let blocker_count = combat_system.blockers
                        .values()
                        .filter(|block_data| block_data.blocked_attackers.contains(attacker))
                        .count();
                    
                    // Menace requires at least 2 blockers
                    if blocker_count == 1 {
                        // Find the single blocker
                        let blocker = combat_system.blockers
                            .iter()
                            .find_map(|(blocker, block_data)| {
                                if block_data.blocked_attackers.contains(attacker) {
                                    Some(*blocker)
                                } else {
                                    None
                                }
                            })
                            .unwrap();
                        
                        return Some((*attacker, blocker));
                    }
                }
            }
            None
        })
        .collect::<Vec<_>>();
    
    // Remove blocks that violate menace
    for (attacker, blocker) in menace_violations {
        // Remove the block relationship
        if let Some(mut block_data) = combat_system.blockers.get_mut(&blocker) {
            block_data.blocked_attackers.retain(|a| *a != attacker);
            
            // If no more attackers, remove blocker entry
            if block_data.blocked_attackers.is_empty() {
                combat_system.blockers.remove(&blocker);
            }
        }
        
        // Emit event for illegal block
        block_events.send(BlockValidationEvent::IllegalBlock {
            blocker,
            attacker,
            reason: "Menace requires at least two blockers".to_string(),
        });
    }
}
}

Vigilance

#![allow(unused)]
fn main() {
pub fn apply_vigilance_system(
    combat_system: Res<CombatSystem>,
    creature_query: Query<(Entity, &Creature)>,
    mut commands: Commands,
) {
    // Find attackers with vigilance and ensure they don't get tapped
    for (attacker, _) in combat_system.attackers.iter() {
        if let Ok((entity, creature)) = creature_query.get(*attacker) {
            if creature.has_keyword(Keyword::Vigilance) {
                // Remove the Tapped component if it exists
                commands.entity(*attacker).remove::<Tapped>();
            }
        }
    }
}
}

Triggered Combat Abilities

Triggered abilities are a major part of combat in Magic. These are implemented using a trigger system:

#![allow(unused)]
fn main() {
#[derive(Debug, Clone)]
pub enum Trigger {
    // Combat-related triggers
    WhenAttacks { conditions: Vec<TriggerCondition> },
    WhenBlocks { conditions: Vec<TriggerCondition> },
    WhenBlocked { conditions: Vec<TriggerCondition> },
    WhenDealsCombat { conditions: Vec<TriggerCondition> },
    WheneverCreatureAttacks { conditions: Vec<TriggerCondition> },
    WheneverCreatureBlocks { conditions: Vec<TriggerCondition> },
    BeginningOfCombat { controller_condition: ControllerCondition },
    EndOfCombat { controller_condition: ControllerCondition },
    // Other triggers...
}

pub fn combat_trigger_system(
    combat_system: Res<CombatSystem>,
    turn_manager: Res<TurnManager>,
    mut stack: ResMut<Stack>,
    entity_query: Query<(Entity, &Card, &Abilities)>,
) {
    // Only run during the appropriate combat step
    if !matches!(turn_manager.current_phase, 
        Phase::Combat(CombatStep::DeclareAttackers) | 
        Phase::Combat(CombatStep::DeclareBlockers) | 
        Phase::Combat(CombatStep::CombatDamage)) {
        return;
    }
    
    // Collect triggered abilities based on the current combat step
    let mut triggered_abilities = Vec::new();
    
    // Check all entities with abilities
    for (entity, card, abilities) in entity_query.iter() {
        for ability in &abilities.0 {
            if let Ability::Triggered(trigger, effect) = ability {
                match trigger {
                    // Check if we're in declare attackers and this is a "when attacks" trigger
                    Trigger::WhenAttacks { conditions } 
                        if turn_manager.current_phase == Phase::Combat(CombatStep::DeclareAttackers) => {
                        // Check if this entity is attacking
                        if let Some(attack_data) = combat_system.attackers.get(&entity) {
                            // Check if conditions are met
                            if all_conditions_met(conditions, entity, attack_data.defender) {
                                triggered_abilities.push((entity, effect.clone(), get_controller(entity)));
                            }
                        }
                    },
                    
                    // Similar checks for other triggers...
                    _ => {}
                }
            }
        }
    }
    
    // Add triggered abilities to the stack in APNAP order
    let active_player = turn_manager.get_active_player();
    let ordered_triggers = order_triggers_by_apnap(triggered_abilities, active_player);
    
    for (source, effect, controller) in ordered_triggers {
        stack.push(StackItem::Ability {
            source,
            effect,
            controller,
        });
    }
}
}

Special Combat Abilities

Ninjutsu

#![allow(unused)]
fn main() {
pub fn handle_ninjutsu_system(
    mut combat_system: ResMut<CombatSystem>,
    turn_manager: Res<TurnManager>,
    mut commands: Commands,
    card_query: Query<(Entity, &Card, &Abilities, Option<&Commander>)>,
    mut activation_events: EventReader<NinjutsuActivationEvent>,
    mut creature_query: Query<(Entity, &mut Creature)>,
) {
    // Only active during declare attackers step after attacks are declared
    if turn_manager.current_phase != Phase::Combat(CombatStep::DeclareAttackers) 
       || combat_system.attackers.is_empty() {
        return;
    }
    
    // Process ninjutsu activations
    for event in activation_events.iter() {
        if let Ok((entity, card, abilities, commander)) = card_query.get(event.ninja) {
            // Verify card has ninjutsu or commander ninjutsu
            let has_ninjutsu = abilities.0.iter().any(|ability| {
                matches!(ability, Ability::Activated(ActivationCost::Ninjutsu(_), _))
            });
            
            let has_commander_ninjutsu = commander.is_some() && abilities.0.iter().any(|ability| {
                matches!(ability, Ability::Activated(ActivationCost::CommanderNinjutsu(_), _))
            });
            
            if has_ninjutsu || has_commander_ninjutsu {
                // Verify unblocked attacker
                if !combat_system.attackers.contains_key(&event.unblocked_attacker) {
                    continue;
                }
                
                // Verify attacker is unblocked
                let is_blocked = combat_system.blockers.values().any(|block_data| {
                    block_data.blocked_attackers.contains(&event.unblocked_attacker)
                });
                
                if is_blocked {
                    continue;
                }
                
                // Get defender
                let defender = combat_system.attackers[&event.unblocked_attacker].defender;
                
                // Return unblocked attacker to hand
                // Implementation details omitted for brevity
                
                // Put ninja onto battlefield tapped and attacking
                // Implementation details omitted for brevity
                
                // Update combat system
                combat_system.attackers.remove(&event.unblocked_attacker);
                combat_system.attackers.insert(event.ninja, AttackData {
                    attacker: event.ninja,
                    defender,
                    is_commander: commander.is_some(),
                    requirements: Vec::new(),
                    restrictions: Vec::new(),
                });
            }
        }
    }
}
}

Protection

#![allow(unused)]
fn main() {
pub fn apply_protection_system(
    mut combat_system: ResMut<CombatSystem>,
    creature_query: Query<(Entity, &Creature, &ActiveEffects)>,
    mut combat_events: EventWriter<CombatEvent>,
) {
    // Check for protection effects
    let mut invalid_blocks = Vec::new();
    let mut prevented_damage = Vec::new();
    
    // Check blocks against protection
    for (blocker, block_data) in combat_system.blockers.iter() {
        if let Ok((blocker_entity, _, effects)) = creature_query.get(*blocker) {
            for attacker in &block_data.blocked_attackers {
                if has_protection_from(*blocker, *attacker, &creature_query) {
                    invalid_blocks.push((*blocker, *attacker));
                }
            }
        }
    }
    
    // Check damage against protection
    let damage_events = combat_system.combat_history
        .iter()
        .filter_map(|event| {
            if let CombatEvent::DamageDealt { source, target, amount, is_commander_damage } = event {
                Some((*source, *target, *amount, *is_commander_damage))
            } else {
                None
            }
        })
        .collect::<Vec<_>>();
    
    for (source, target, amount, is_commander_damage) in damage_events {
        if has_protection_from(target, source, &creature_query) {
            prevented_damage.push((source, target, amount, is_commander_damage));
        }
    }
    
    // Remove invalid blocks
    for (blocker, attacker) in invalid_blocks {
        if let Some(mut block_data) = combat_system.blockers.get_mut(&blocker) {
            block_data.blocked_attackers.retain(|a| *a != attacker);
        }
    }
    
    // Record damage prevention events
    for (source, target, amount, is_commander_damage) in prevented_damage {
        combat_events.send(CombatEvent::DamagePrevented {
            source,
            target,
            amount,
            reason: PreventionReason::Protection,
        });
    }
}

// Helper function to check if an entity has protection from another
fn has_protection_from(
    protected: Entity,
    source: Entity,
    creature_query: &Query<(Entity, &Creature, &ActiveEffects)>,
) -> bool {
    if let Ok((_, _, effects)) = creature_query.get(protected) {
        for effect in &effects.0 {
            match effect {
                Effect::ProtectionFromColor(color) => {
                    // Check if source has the protected color
                    if let Ok((_, source_creature, _)) = creature_query.get(source) {
                        if source_creature.colors.contains(color) {
                            return true;
                        }
                    }
                },
                Effect::ProtectionFromCreatureType(creature_type) => {
                    // Check if source has the protected creature type
                    if let Ok((_, source_creature, _)) = creature_query.get(source) {
                        if source_creature.types.contains(creature_type) {
                            return true;
                        }
                    }
                },
                Effect::ProtectionFromPlayer(player) => {
                    // Check if source is controlled by the protected player
                    // Implementation details omitted for brevity
                },
                Effect::ProtectionFromEverything => {
                    return true;
                },
                _ => {}
            }
        }
    }
    
    false
}
}

Edge Cases

Multiple Combats

Some cards create additional combat phases. These need special handling:

#![allow(unused)]
fn main() {
pub fn handle_additional_combat_phases(
    mut turn_manager: ResMut<TurnManager>,
    mut combat_system: ResMut<CombatSystem>,
    mut creature_query: Query<(Entity, &mut Creature)>,
    mut phase_events: EventReader<PhaseEvent>,
) {
    // Check for additional combat phase events
    for event in phase_events.iter() {
        if let PhaseEvent::AdditionalPhase { phase, source } = event {
            if matches!(phase, Phase::Combat(_)) {
                // Reset combat state for the new phase
                combat_system.reset();
                combat_system.is_additional_combat_phase = true;
                
                // Reset attacked/blocked flags on creatures
                for (entity, mut creature) in creature_query.iter_mut() {
                    creature.attacking = None;
                    creature.blocking.clear();
                }
                
                // Update turn manager
                turn_manager.additional_phases.push_back((*phase, *source));
            }
        }
    }
}
}

Changing Abilities During Combat

Creatures can gain or lose abilities during combat:

#![allow(unused)]
fn main() {
pub fn update_combat_abilities_system(
    mut creature_query: Query<(Entity, &mut Creature, &ActiveEffects)>,
    mut combat_system: ResMut<CombatSystem>,
    mut ability_events: EventReader<AbilityChangeEvent>,
) {
    // Process ability change events
    for event in ability_events.iter() {
        if let AbilityChangeEvent::GainedKeyword { entity, keyword } = event {
            // Update entity if it's involved in combat
            if combat_system.attackers.contains_key(entity) || 
               combat_system.blockers.contains_key(entity) {
                // Special handling for combat-relevant keywords
                match keyword {
                    Keyword::FirstStrike | Keyword::DoubleStrike => {
                        // May need to recalculate damage for first strike step
                        if combat_system.active_combat_step == Some(CombatStep::DeclareBlockers) {
                            // Flag that first strike needs to be checked
                            combat_system.first_strike_recalculation_needed = true;
                        }
                    },
                    Keyword::Flying => {
                        // May need to recalculate blocks for flying
                        if let Some(block_data) = combat_system.blockers.get(entity) {
                            // Check if this creature is now blocking a flyer illegally
                            // Implementation details omitted for brevity
                        }
                    },
                    Keyword::Vigilance => {
                        // If creature already attacked, untap it
                        if combat_system.attackers.contains_key(entity) {
                            // Implementation details omitted for brevity
                        }
                    },
                    // Handle other keywords...
                    _ => {}
                }
            }
        }
        else if let AbilityChangeEvent::LostKeyword { entity, keyword } = event {
            // Similar handling for losing keywords
            // Implementation details omitted for brevity
        }
    }
}
}

Testing Strategy

Unit Tests for Combat Abilities

#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
    use super::*;
    
    #[test]
    fn test_deathtouch() {
        let mut app = App::new();
        app.add_systems(Update, apply_deathtouch_system);
        
        // Setup test entities with deathtouch
        let attacker = app.world.spawn((
            Card::default(),
            Creature { power: 1, toughness: 1, ..Default::default() },
            Abilities(vec![Ability::Keyword(Keyword::Deathtouch)]),
        )).id();
        
        let blocker = app.world.spawn((
            Card::default(),
            Creature { power: 5, toughness: 5, ..Default::default() },
        )).id();
        
        // Create combat history with damage event
        let mut combat_system = CombatSystem::default();
        combat_system.combat_history.push_back(CombatEvent::DamageDealt {
            source: attacker,
            target: blocker,
            amount: 1,
            is_commander_damage: false,
        });
        app.insert_resource(combat_system);
        
        // Run the system
        app.update();
        
        // Verify blocker was destroyed by deathtouch
        assert!(app.world.entity(blocker).contains::<Destroyed>());
    }
    
    #[test]
    fn test_trample() {
        let mut app = App::new();
        app.add_systems(Update, apply_trample_damage_system);
        
        // Setup test entities
        let player = app.world.spawn((
            Player { life_total: 40, commander_damage: HashMap::new(), ..Default::default() },
        )).id();
        
        let attacker = app.world.spawn((
            Card::default(),
            Creature { power: 5, toughness: 5, ..Default::default() },
            Abilities(vec![Ability::Keyword(Keyword::Trample)]),
        )).id();
        
        let blocker = app.world.spawn((
            Card::default(),
            Creature { power: 2, toughness: 2, ..Default::default() },
        )).id();
        
        // Create combat system with attacker and blocker
        let mut combat_system = CombatSystem::default();
        combat_system.attackers.insert(attacker, AttackData {
            attacker,
            defender: player,
            is_commander: false,
            requirements: Vec::new(),
            restrictions: Vec::new(),
        });
        
        combat_system.blockers.insert(blocker, BlockData {
            blocker,
            blocked_attackers: vec![attacker],
            requirements: Vec::new(),
            restrictions: Vec::new(),
        });
        
        app.insert_resource(combat_system);
        
        // Run the system
        app.update();
        
        // Verify trample damage was dealt to player (5 power - 2 toughness = 3 damage)
        let player_component = app.world.entity(player).get::<Player>().unwrap();
        assert_eq!(player_component.life_total, 40 - 3);
        
        // Verify combat history contains damage event
        let combat_system = app.world.resource::<CombatSystem>();
        assert!(combat_system.combat_history.iter().any(|event| {
            matches!(event, CombatEvent::DamageDealt { source, target, amount, .. } 
                if *source == attacker && *target == player && *amount == 3)
        }));
    }
    
    #[test]
    fn test_flying_and_reach() {
        let mut app = App::new();
        app.add_systems(Update, validate_blocks_flying_system);
        
        // Setup test entities
        let flying_creature = app.world.spawn((
            Card::default(),
            Creature { power: 3, toughness: 3, ..Default::default() },
            Abilities(vec![Ability::Keyword(Keyword::Flying)]),
        )).id();
        
        let normal_creature = app.world.spawn((
            Card::default(),
            Creature { power: 2, toughness: 2, ..Default::default() },
        )).id();
        
        let reach_creature = app.world.spawn((
            Card::default(),
            Creature { power: 1, toughness: 4, ..Default::default() },
            Abilities(vec![Ability::Keyword(Keyword::Reach)]),
        )).id();
        
        // Create combat system with illegal block
        let mut combat_system = CombatSystem::default();
        combat_system.attackers.insert(flying_creature, AttackData {
            attacker: flying_creature,
            defender: Entity::from_raw(999), // Dummy defender
            is_commander: false,
            requirements: Vec::new(),
            restrictions: Vec::new(),
        });
        
        combat_system.blockers.insert(normal_creature, BlockData {
            blocker: normal_creature,
            blocked_attackers: vec![flying_creature],
            requirements: Vec::new(),
            restrictions: Vec::new(),
        });
        
        combat_system.blockers.insert(reach_creature, BlockData {
            blocker: reach_creature,
            blocked_attackers: vec![flying_creature],
            requirements: Vec::new(),
            restrictions: Vec::new(),
        });
        
        app.insert_resource(combat_system);
        
        // Set up event listener
        app.add_event::<BlockValidationEvent>();
        
        // Run the system
        app.update();
        
        // Verify normal creature can't block the flyer
        let combat_system = app.world.resource::<CombatSystem>();
        assert!(!combat_system.blockers.get(&normal_creature)
            .map_or(false, |data| data.blocked_attackers.contains(&flying_creature)));
        
        // Verify reach creature can still block the flyer
        assert!(combat_system.blockers.get(&reach_creature)
            .map_or(false, |data| data.blocked_attackers.contains(&flying_creature)));
    }
    
    // Additional tests...
}
}

Integration Tests for Combat Abilities

#![allow(unused)]
fn main() {
#[cfg(test)]
mod integration_tests {
    use super::*;
    
    #[test]
    fn test_keyword_interactions() {
        let mut builder = CombatScenarioBuilder::new();
        
        // Setup a combat scenario with multiple keyword interactions
        let opponent = builder.add_player();
        
        // Flying attacker with lifelink and double strike
        let attacker = builder.add_attacker(3, 3, builder.active_player, false);
        builder.add_effect(attacker, Effect::Keyword(Keyword::Flying));
        builder.add_effect(attacker, Effect::Keyword(Keyword::Lifelink));
        builder.add_effect(attacker, Effect::Keyword(Keyword::DoubleStrike));
        
        // Normal blocker can't block flying
        let blocker1 = builder.add_blocker(2, 2, opponent);
        
        // Reach blocker can block flying
        let blocker2 = builder.add_blocker(1, 1, opponent);
        builder.add_effect(blocker2, Effect::Keyword(Keyword::Reach));
        
        // Set up attacks and blocks
        builder.declare_attacks(vec![(attacker, opponent)]);
        builder.declare_blocks(vec![(blocker2, vec![attacker])]);
        
        // Execute combat
        let result = builder.execute();
        
        // Verify: 
        // 1. First strike damage killed blocker2
        // 2. Double strike allowed second hit to player
        // 3. Lifelink gained life
        
        // Check blocker died
        let blocker2_status = result.creature_status.get(&blocker2).unwrap();
        assert!(blocker2_status.destroyed);
        
        // Check player took damage from second hit (double strike)
        assert_eq!(result.player_life[&opponent], 40 - 3);
        
        // Check controller gained life from lifelink
        let active_player_life = result.player_life[&builder.active_player];
        assert_eq!(active_player_life, 40 + 6); // 3 damage twice from double strike
    }
    
    #[test]
    fn test_triggered_abilities() {
        // Test implementation for combat-triggered abilities
        // Implementation details omitted for brevity
    }
    
    #[test]
    fn test_ninjutsu() {
        // Test implementation for ninjutsu
        // Implementation details omitted for brevity
    }
    
    // Additional integration tests...
}
}

Combos and Interactions

The combat system needs to handle complex ability interactions:

#![allow(unused)]
fn main() {
pub fn handle_ability_interactions(
    combat_system: Res<CombatSystem>,
    entity_query: Query<(Entity, &Creature, &ActiveEffects)>,
    mut destroy_events: EventWriter<DestroyEvent>,
    mut damage_events: EventWriter<DamageEvent>,
) {
    // Track relevant ability combinations
    let mut indestructible_entities = HashSet::new();
    let mut damage_redirection_map = HashMap::new();
    let mut regeneration_shields = HashMap::new();
    
    // Collect all combat-relevant abilities
    for (entity, _, effects) in entity_query.iter() {
        for effect in &effects.0 {
            match effect {
                Effect::Indestructible => {
                    indestructible_entities.insert(entity);
                },
                Effect::RedirectDamage { target } => {
                    damage_redirection_map.insert(entity, *target);
                },
                Effect::RegenerationShield => {
                    regeneration_shields.entry(entity).or_insert(0) += 1;
                },
                // Handle other effects
                _ => {}
            }
        }
    }
    
    // Process combat damage events considering special abilities
    for event in combat_system.combat_history.iter() {
        if let CombatEvent::DamageDealt { source, target, amount, is_commander_damage } = event {
            // Check for damage redirection
            let actual_target = damage_redirection_map.get(target).copied().unwrap_or(*target);
            
            // Check if damage would be lethal
            if let Ok((_, creature, _)) = entity_query.get(actual_target) {
                if creature.toughness <= creature.damage + amount {
                    // Check for indestructible
                    if indestructible_entities.contains(&actual_target) {
                        // Creature survives despite lethal damage
                        continue;
                    }
                    
                    // Check for regeneration shield
                    if let Some(shields) = regeneration_shields.get_mut(&actual_target) {
                        if *shields > 0 {
                            // Use a regeneration shield
                            *shields -= 1;
                            
                            // Emit regeneration event
                            // Implementation details omitted
                            
                            continue;
                        }
                    }
                    
                    // No protection, creature is destroyed
                    destroy_events.send(DestroyEvent {
                        entity: actual_target,
                        source: *source,
                        reason: DestructionReason::LethalDamage,
                    });
                }
            }
        }
    }
}
}

Conclusion

Combat abilities add significant complexity to the Commander format, requiring careful implementation and testing. The systems described here work together to handle the various keywords, triggers, and special cases that can arise during combat. By properly implementing and testing these abilities, we ensure that the combat system correctly follows the rules of Magic: The Gathering while providing the strategic depth that makes Commander such a popular format.

Combat Tests

This section contains tests related to the combat system in the Commander format, focusing on both the standard combat rules and Commander-specific interactions.

Subsections

Edge Cases

Tests for complex and edge case combat scenarios, including Commander-specific interactions and intricate card combinations.

Phasing

Tests related to phasing effects during combat, including their impact on attackers, blockers, and combat damage.

Pump Spells

Tests for combat interactions with pump spells and other power/toughness modifying effects that occur during combat.

Combat Edge Cases Tests

This section contains tests for complex and edge case scenarios in the combat system that are specific to the Commander format.

Tests

Commander-Specific Interactions

Tests for combat mechanics that interact with Commander-specific rules, such as:

  • Commander damage tracking
  • Combat abilities on Commander cards
  • Commander-specific combat triggers

Complex Interactions

Tests for intricate card combinations and rules interactions during combat, including:

  • Multiple blocking and damage assignment
  • Replacement effects during combat
  • Unusual attack and block restrictions
  • Multiple combat phases
  • Layer system interactions affecting combat

Commander-Specific Combat Tests

Overview

This document outlines test cases for Commander-specific combat scenarios, focusing on the unique rules and edge cases that arise in the Commander format. These tests are critical for ensuring our Commander engine correctly handles format-specific interactions during combat.

Test Case: Commander Damage Tracking

Test: Multiple Sources of Commander Damage

#![allow(unused)]
fn main() {
#[test]
fn test_multiple_commanders_damage_tracking() {
    // Test setup
    let mut app = App::new();
    app.add_plugins(MinimalPlugins)
       .add_systems(Update, (combat_damage_system, state_based_actions_system));
       
    // Create player
    let player = app.world.spawn((
        Player {},
        Health { current: 40, maximum: 40 },
        CommanderDamage::default(),
    )).id();
    
    // Create commanders from different players
    let commander1 = app.world.spawn((
        Creature { power: 5, toughness: 5 },
        Commander { owner: opponent1 },
        Attacking { defending: player },
    )).id();
    
    let commander2 = app.world.spawn((
        Creature { power: 3, toughness: 3 },
        Commander { owner: opponent2 },
        Attacking { defending: player },
    )).id();
    
    let opponent1 = app.world.spawn(Player {}).id();
    let opponent2 = app.world.spawn(Player {}).id();
    
    // Process combat damage for first attack
    app.world.resource_mut::<TurnManager>().current_phase = Phase::Combat(CombatStep::CombatDamage);
    app.update();
    
    // Verify commander damage was tracked separately
    let cmd_damage = app.world.get::<CommanderDamage>(player).unwrap();
    assert_eq!(cmd_damage.get_damage(commander1), 5);
    assert_eq!(cmd_damage.get_damage(commander2), 3);
    assert_eq!(cmd_damage.total_damage(), 8);
    
    // Player's health should also be reduced
    let health = app.world.get::<Health>(player).unwrap();
    assert_eq!(health.current, 32); // 40 - 5 - 3 = 32
    
    // More damage from commander1 in a later combat
    app.world.entity_mut(commander1).insert(
        DamageEvent {
            source: commander1,
            target: player,
            amount: 5,
            is_combat_damage: true,
        }
    );
    
    // Process the damage
    app.update();
    
    // Check state-based actions after damage
    app.update();
    
    // Verify commander damage accumulated correctly
    let cmd_damage_after = app.world.get::<CommanderDamage>(player).unwrap();
    assert_eq!(cmd_damage_after.get_damage(commander1), 10);
    assert_eq!(cmd_damage_after.get_damage(commander2), 3);
    
    // Verify player still alive (no commander has dealt 21+ damage yet)
    assert!(app.world.entity(player).contains::<Health>());
    
    // Deal more damage from commander1 to reach lethal commander damage
    app.world.entity_mut(commander1).insert(
        DamageEvent {
            source: commander1,
            target: player,
            amount: 11,
            is_combat_damage: true,
        }
    );
    
    // Process the damage
    app.update();
    
    // Check state-based actions after damage
    app.update();
    
    // Verify player lost due to commander damage
    let cmd_damage_final = app.world.get::<CommanderDamage>(player).unwrap();
    assert_eq!(cmd_damage_final.get_damage(commander1), 21);
    assert!(app.world.entity(player).contains::<PlayerEliminated>());
}
}

Test: Commander Damage from Copied Commander

#![allow(unused)]
fn main() {
#[test]
fn test_commander_damage_from_clone() {
    // Test setup
    let mut app = App::new();
    app.add_plugins(MinimalPlugins)
       .add_systems(Update, (combat_damage_system, clone_effects_system));
       
    // Create player
    let player = app.world.spawn((
        Player {},
        Health { current: 40, maximum: 40 },
        CommanderDamage::default(),
    )).id();
    
    // Create commander
    let commander = app.world.spawn((
        Creature { power: 5, toughness: 5 },
        Commander { owner: opponent },
        CreatureCard { controller: opponent },
    )).id();
    
    let opponent = app.world.spawn(Player {}).id();
    
    // Create clone of the commander
    let clone = app.world.spawn((
        Creature { power: 5, toughness: 5 },
        CloneEffect { source: commander },
        CreatureCard { controller: opponent },
    )).id();
    
    // Make clone attack
    app.world.entity_mut(clone).insert(Attacking { defending: player });
    
    // Process combat damage
    app.world.resource_mut::<TurnManager>().current_phase = Phase::Combat(CombatStep::CombatDamage);
    app.update();
    
    // Verify damage from clone is NOT commander damage
    let cmd_damage = app.world.get::<CommanderDamage>(player).unwrap();
    assert_eq!(cmd_damage.get_damage(commander), 0);
    assert_eq!(cmd_damage.get_damage(clone), 0);
    
    // Player's health should still be reduced
    let health = app.world.get::<Health>(player).unwrap();
    assert_eq!(health.current, 35); // 40 - 5 = 35
}
}

Test Case: Commander Combat Abilities

Test: Commander with Partner

#![allow(unused)]
fn main() {
#[test]
fn test_partner_commanders_in_combat() {
    // Test setup
    let mut app = App::new();
    app.add_plugins(MinimalPlugins)
       .add_systems(Update, (combat_damage_system, state_based_actions_system));
       
    // Create player
    let player = app.world.spawn((
        Player {},
        Health { current: 40, maximum: 40 },
        CommanderDamage::default(),
    )).id();
    
    // Create partner commanders
    let partner1 = app.world.spawn((
        Creature { power: 3, toughness: 3 },
        Commander { owner: opponent },
        Partner {},
        Attacking { defending: player },
    )).id();
    
    let partner2 = app.world.spawn((
        Creature { power: 2, toughness: 2 },
        Commander { owner: opponent },
        Partner {},
        Attacking { defending: player },
    )).id();
    
    let opponent = app.world.spawn(Player {}).id();
    
    // Process combat damage
    app.world.resource_mut::<TurnManager>().current_phase = Phase::Combat(CombatStep::CombatDamage);
    app.update();
    
    // Verify commander damage was tracked separately for each partner
    let cmd_damage = app.world.get::<CommanderDamage>(player).unwrap();
    assert_eq!(cmd_damage.get_damage(partner1), 3);
    assert_eq!(cmd_damage.get_damage(partner2), 2);
    
    // Player needs to take 21 damage from a single partner to lose
    // Simulate multiple combats to test this
    for _ in 0..6 {
        app.world.entity_mut(partner1).insert(
            DamageEvent {
                source: partner1,
                target: player,
                amount: 3,
                is_combat_damage: true,
            }
        );
        app.update();
    }
    
    // Check state-based actions
    app.update();
    
    // Verify player lost due to commander damage from one partner
    let cmd_damage_final = app.world.get::<CommanderDamage>(player).unwrap();
    assert_eq!(cmd_damage_final.get_damage(partner1), 21);
    assert!(app.world.entity(player).contains::<PlayerEliminated>());
}
}

Test: Commander with Combat Damage Triggers

#![allow(unused)]
fn main() {
#[test]
fn test_commander_combat_damage_triggers() {
    // Test setup
    let mut app = App::new();
    app.add_plugins(MinimalPlugins)
       .add_systems(Update, (combat_damage_system, trigger_system));
       
    // Create player
    let player = app.world.spawn((
        Player {},
        Health { current: 40, maximum: 40 },
        CommanderDamage::default(),
    )).id();
    
    // Create commander with combat damage trigger
    let commander = app.world.spawn((
        Creature { power: 5, toughness: 5 },
        Commander { owner: opponent },
        Attacking { defending: player },
        CombatDamageTrigger { 
            effect: TriggerEffect::DrawCards { 
                player: opponent,
                count: 1,
            }
        },
    )).id();
    
    let opponent = app.world.spawn((
        Player {},
        Library { cards: vec![card1, card2, card3] },
        Hand { cards: vec![] },
    )).id();
    
    let card1 = app.world.spawn(Card {}).id();
    let card2 = app.world.spawn(Card {}).id();
    let card3 = app.world.spawn(Card {}).id();
    
    // Process combat damage
    app.world.resource_mut::<TurnManager>().current_phase = Phase::Combat(CombatStep::CombatDamage);
    app.update();
    
    // Process triggers
    app.update();
    
    // Verify commander damage was tracked
    let cmd_damage = app.world.get::<CommanderDamage>(player).unwrap();
    assert_eq!(cmd_damage.get_damage(commander), 5);
    
    // Verify combat damage trigger resolved
    let hand = app.world.get::<Hand>(opponent).unwrap();
    assert_eq!(hand.cards.len(), 1);
}
}

Test Case: Multiplayer Combat Scenarios

Test: Attacking Multiple Players with Commander

#![allow(unused)]
fn main() {
#[test]
fn test_attacking_multiple_players_with_commander() {
    // Test setup
    let mut app = App::new();
    app.add_plugins(MinimalPlugins)
       .add_systems(Update, (declare_attackers_system, combat_damage_system));
       
    // Create players
    let player1 = app.world.spawn((
        Player {},
        Health { current: 40, maximum: 40 },
        CommanderDamage::default(),
    )).id();
    
    let player2 = app.world.spawn((
        Player {},
        Health { current: 40, maximum: 40 },
        CommanderDamage::default(),
    )).id();
    
    let active_player = app.world.spawn((
        Player {},
        ActivePlayer {},
    )).id();
    
    // Create commander with vigilance (can attack multiple players)
    let commander = app.world.spawn((
        Creature { power: 5, toughness: 5 },
        Commander { owner: active_player },
        CreatureCard { controller: active_player },
        Vigilance {},
    )).id();
    
    // Create additional attacker
    let other_attacker = app.world.spawn((
        Creature { power: 3, toughness: 3 },
        CreatureCard { controller: active_player },
    )).id();
    
    // Declare attackers for different players
    app.world.resource_mut::<AttackDeclaration>().declare_attacker(commander, player1);
    app.world.resource_mut::<AttackDeclaration>().declare_attacker(other_attacker, player2);
    
    // Process declare attackers
    app.world.resource_mut::<TurnManager>().current_phase = Phase::Combat(CombatStep::DeclareAttackers);
    app.update();
    
    // Process combat damage
    app.world.resource_mut::<TurnManager>().current_phase = Phase::Combat(CombatStep::CombatDamage);
    app.update();
    
    // Verify commander damage was tracked for player1 only
    let cmd_damage1 = app.world.get::<CommanderDamage>(player1).unwrap();
    assert_eq!(cmd_damage1.get_damage(commander), 5);
    
    let cmd_damage2 = app.world.get::<CommanderDamage>(player2).unwrap();
    assert_eq!(cmd_damage2.get_damage(commander), 0);
    
    // Verify health reduction for both players
    let health1 = app.world.get::<Health>(player1).unwrap();
    assert_eq!(health1.current, 35); // 40 - 5 = 35
    
    let health2 = app.world.get::<Health>(player2).unwrap();
    assert_eq!(health2.current, 37); // 40 - 3 = 37
}
}

Test: Goad Effect on Commander

#![allow(unused)]
fn main() {
#[test]
fn test_goaded_commander() {
    // Test setup
    let mut app = App::new();
    app.add_plugins(MinimalPlugins)
       .add_systems(Update, (declare_attackers_system, attack_restrictions_system));
       
    // Create players
    let player1 = app.world.spawn(Player {}).id();
    let player2 = app.world.spawn(Player {}).id();
    let player3 = app.world.spawn(Player {}).id();
    
    // Create commander that's been goaded by player2
    let commander = app.world.spawn((
        Creature { power: 5, toughness: 5 },
        Commander { owner: player1 },
        CreatureCard { controller: player1 },
        Goad { goaded_by: player2 },
    )).id();
    
    // Set up attack validation
    app.world.resource_mut::<TurnManager>().active_player = player1;
    app.world.resource_mut::<CombatSystem>().validate_attack = |attacker, defender, world| {
        // Basic validation - can't attack yourself
        let creature_card = world.get::<CreatureCard>(attacker).unwrap();
        if creature_card.controller == defender {
            return Err(AttackError::IllegalTarget);
        }
        
        // Check goad restrictions
        if let Some(goad) = world.get::<Goad>(attacker) {
            if goad.goaded_by != defender && defender != player3 {
                return Err(AttackError::MustAttackGoader);
            }
        }
        
        Ok(())
    };
    
    // Try to attack player3 (allowed because not the goader)
    assert!(app.world.resource::<CombatSystem>()
        .validate_attack(commander, player3, &app.world)
        .is_ok());
    
    // Try to attack player2 (allowed because this is the goader)
    assert!(app.world.resource::<CombatSystem>()
        .validate_attack(commander, player2, &app.world)
        .is_ok());
    
    // Try to attack self (not allowed)
    assert!(app.world.resource::<CombatSystem>()
        .validate_attack(commander, player1, &app.world)
        .is_err());
    
    // Commander must attack if able
    app.world.resource_mut::<AttackRequirements>().creatures_that_must_attack.push(commander);
    
    // Process attack requirements
    app.update();
    
    // Verify commander is forced to attack
    assert!(app.world.get::<MustAttack>(commander).is_some());
}
}

Test Case: Commander Zone Interactions

Test: Combat Damage Sending Commander to Command Zone

#![allow(unused)]
fn main() {
#[test]
fn test_commander_to_command_zone_from_combat() {
    // Test setup
    let mut app = App::new();
    app.add_plugins(MinimalPlugins)
       .add_systems(Update, (combat_damage_system, state_based_actions_system, zone_change_system));
       
    // Create commander
    let commander = app.world.spawn((
        Creature { power: 3, toughness: 3 },
        Commander { owner: player },
        CreatureCard { controller: player },
        Health { current: 3, maximum: 3 },
    )).id();
    
    let player = app.world.spawn((
        Player {},
        CommandZone { commanders: vec![] },
    )).id();
    
    // Create attacking creature
    let attacker = app.world.spawn((
        Creature { power: 5, toughness: 5 },
        Attacking { defending: player },
    )).id();
    
    // Make commander block
    app.world.entity_mut(commander).insert(
        Blocking { blocked_attackers: vec![attacker] }
    );
    
    // Process combat damage
    app.world.resource_mut::<TurnManager>().current_phase = Phase::Combat(CombatStep::CombatDamage);
    app.update();
    
    // Check state-based actions - commander should die from damage
    app.update();
    
    // Player should be prompted to send commander to command zone
    // We'll simulate them choosing to do so
    app.world.spawn(
        ZoneChangeRequest {
            entity: commander,
            from: Zone::Battlefield,
            to: Zone::CommandZone,
            reason: ZoneChangeReason::Death,
        }
    );
    
    // Process zone change
    app.update();
    
    // Verify commander is in command zone
    assert!(!app.world.entity(commander).contains::<Health>());
    assert!(!app.world.entity(commander).contains::<Blocking>());
    
    let command_zone = app.world.get::<CommandZone>(player).unwrap();
    assert!(command_zone.commanders.contains(&commander));
}
}

Test: Combat with Commander from Command Zone

#![allow(unused)]
fn main() {
#[test]
fn test_cast_commander_from_command_zone() {
    // Test setup
    let mut app = App::new();
    app.add_plugins(MinimalPlugins)
       .add_systems(Update, (cast_from_command_zone_system, declare_attackers_system));
       
    // Create player
    let player = app.world.spawn((
        Player {},
        Mana { 
            white: 5, blue: 5, black: 5, red: 5, green: 5, colorless: 5,
        },
        CommandZone { commanders: vec![commander] },
    )).id();
    
    // Create commander in command zone
    let commander = app.world.spawn((
        Commander { owner: player },
        CreatureCard { controller: player },
        ManaCost {
            white: 0, blue: 0, black: 0, red: 0, green: 0, colorless: 4,
            additional_cost: 0, // No command tax yet
        },
        InZone { zone: Zone::CommandZone },
    )).id();
    
    // Cast commander from command zone
    app.world.spawn(
        CastRequest {
            card: commander,
            controller: player,
            from_zone: Zone::CommandZone,
        }
    );
    
    // Process casting
    app.update();
    
    // Commander should now be on battlefield
    assert!(app.world.entity(commander).contains::<Creature>());
    assert_eq!(app.world.get::<InZone>(commander).unwrap().zone, Zone::Battlefield);
    
    // Verify mana was spent
    let mana_after = app.world.get::<Mana>(player).unwrap();
    assert_eq!(mana_after.colorless, 1); // 5 - 4 = 1
    
    // Declare commander as attacker
    app.world.resource_mut::<TurnManager>().current_phase = Phase::Combat(CombatStep::DeclareAttackers);
    app.world.resource_mut::<TurnManager>().active_player = player;
    
    app.world.resource_mut::<AttackDeclaration>().declare_attacker(commander, Entity::PLACEHOLDER);
    
    // Process declare attackers
    app.update();
    
    // Verify commander is attacking
    assert!(app.world.entity(commander).contains::<Attacking>());
}
}

Test Case: Commander Damage Edge Cases

Test: Commander Damage After Death and Recast

#![allow(unused)]
fn main() {
#[test]
fn test_commander_damage_after_recast() {
    // Test setup
    let mut app = App::new();
    app.add_plugins(MinimalPlugins)
       .add_systems(Update, (combat_damage_system, zone_change_system, cast_from_command_zone_system));
       
    // Create player
    let target_player = app.world.spawn((
        Player {},
        Health { current: 40, maximum: 40 },
        CommanderDamage::default(),
    )).id();
    
    // Create commander owner
    let commander_owner = app.world.spawn((
        Player {},
        Mana { 
            white: 10, blue: 10, black: 10, red: 10, green: 10, colorless: 10,
        },
        CommandZone { commanders: vec![] },
    )).id();
    
    // Create commander
    let commander = app.world.spawn((
        Creature { power: 5, toughness: 5 },
        Commander { owner: commander_owner },
        CreatureCard { controller: commander_owner },
        InZone { zone: Zone::Battlefield },
        ManaCost {
            white: 0, blue: 0, black: 0, red: 0, green: 0, colorless: 5,
            additional_cost: 0,
        },
    )).id();
    
    // Deal commander damage
    app.world.entity_mut(commander).insert(Attacking { defending: target_player });
    app.world.resource_mut::<TurnManager>().current_phase = Phase::Combat(CombatStep::CombatDamage);
    app.update();
    
    // Verify initial commander damage
    let cmd_damage1 = app.world.get::<CommanderDamage>(target_player).unwrap();
    assert_eq!(cmd_damage1.get_damage(commander), 5);
    
    // Commander dies
    app.world.spawn(
        ZoneChangeRequest {
            entity: commander,
            from: Zone::Battlefield,
            to: Zone::CommandZone,
            reason: ZoneChangeReason::Death,
        }
    );
    
    // Process zone change
    app.update();
    
    // Update command zone
    app.world.entity_mut(commander_owner).insert(
        CommandZone { commanders: vec![commander] }
    );
    
    // Update commander in command zone
    app.world.entity_mut(commander).insert(
        InZone { zone: Zone::CommandZone }
    );
    
    // Cast commander from command zone (now with command tax)
    app.world.entity_mut(commander).insert(
        ManaCost {
            white: 0, blue: 0, black: 0, red: 0, green: 0, colorless: 5,
            additional_cost: 2, // Command tax
        }
    );
    
    app.world.spawn(
        CastRequest {
            card: commander,
            controller: commander_owner,
            from_zone: Zone::CommandZone,
        }
    );
    
    // Process casting
    app.update();
    
    // Commander should be back on battlefield
    assert_eq!(app.world.get::<InZone>(commander).unwrap().zone, Zone::Battlefield);
    
    // Deal more commander damage
    app.world.entity_mut(commander).insert(Attacking { defending: target_player });
    app.update();
    
    // Verify commander damage accumulates even after recasting
    let cmd_damage2 = app.world.get::<CommanderDamage>(target_player).unwrap();
    assert_eq!(cmd_damage2.get_damage(commander), 10); // 5 + 5 = 10
}
}

Test: Commander Damage Through Redirect Effects

#![allow(unused)]
fn main() {
#[test]
fn test_commander_damage_with_redirect() {
    // Test setup
    let mut app = App::new();
    app.add_plugins(MinimalPlugins)
       .add_systems(Update, (combat_damage_system, replacement_effects_system));
       
    // Create players
    let player1 = app.world.spawn((
        Player {},
        Health { current: 40, maximum: 40 },
        CommanderDamage::default(),
    )).id();
    
    let player2 = app.world.spawn((
        Player {},
        Health { current: 40, maximum: 40 },
        CommanderDamage::default(),
        DamageReplacementEffect { 
            effect_type: ReplacementType::Redirect { 
                percentage: 100,
                target: TargetType::Player(player3),
                round_up: true,
            }
        },
    )).id();
    
    let player3 = app.world.spawn((
        Player {},
        Health { current: 40, maximum: 40 },
        CommanderDamage::default(),
    )).id();
    
    // Create commander
    let commander = app.world.spawn((
        Creature { power: 5, toughness: 5 },
        Commander { owner: player1 },
        CreatureCard { controller: player1 },
        Attacking { defending: player2 },
    )).id();
    
    // Process combat damage
    app.world.resource_mut::<TurnManager>().current_phase = Phase::Combat(CombatStep::CombatDamage);
    app.update();
    
    // Verify damage was redirected
    let health2 = app.world.get::<Health>(player2).unwrap();
    assert_eq!(health2.current, 40); // No damage taken
    
    let health3 = app.world.get::<Health>(player3).unwrap();
    assert_eq!(health3.current, 35); // 40 - 5 = 35
    
    // Verify commander damage is tracked for the final recipient
    let cmd_damage3 = app.world.get::<CommanderDamage>(player3).unwrap();
    assert_eq!(cmd_damage3.get_damage(commander), 5);
    
    // Original target should not have commander damage
    let cmd_damage2 = app.world.get::<CommanderDamage>(player2).unwrap();
    assert_eq!(cmd_damage2.get_damage(commander), 0);
}
}

Performance Considerations for Commander Combat Tests

  1. Efficient Commander Damage Tracking: Optimize how commander damage is tracked and accumulated.

  2. Zone Change Optimizations: Efficiently handle commander zone changes during and after combat.

  3. Multiplayer Combat Performance: Ensure combat involving multiple players remains performant.

Test Coverage Checklist

  • Multiple sources of commander damage tracking
  • Commander damage from clones/copies
  • Partner commanders in combat
  • Combat damage triggers from commanders
  • Attacking multiple players with a commander
  • Goaded commanders
  • Commander dying in combat and going to command zone
  • Casting commander from command zone for combat
  • Commander damage persistence after recasting
  • Commander damage with redirection effects

Additional Commander-Specific Edge Cases

  1. Multiple copies of the same commander (from Clone effects)
  2. Commanders with alternate combat damage effects (infect, wither)
  3. Face-down commanders (via Morph or similar effects)
  4. Commander damage with replacement effects (damage doubling)
  5. "Voltron" strategies with heavily equipped/enchanted commanders

Complex Combat Edge Cases - Test Documentation

Overview

This document outlines test cases for complex interactions in combat that involve multiple mechanics working together. These scenarios test the robustness of our Commander engine when dealing with intricate card interactions and edge cases.

Test Case: Multiple Triggers During Combat

Test: Death Triggers During Combat

#![allow(unused)]
fn main() {
#[test]
fn test_multiple_death_triggers_during_combat() {
    // Test setup
    let mut app = App::new();
    app.add_plugins(MinimalPlugins)
       .add_systems(Update, (combat_damage_system, trigger_system, state_based_actions_system));
       
    // Create creatures with death triggers
    let creature1 = app.world.spawn((
        Creature { power: 3, toughness: 3 },
        Attacking { defending: defender_entity },
        Health { current: 3, maximum: 3 },
        DiesTrigger { 
            effect: TriggerEffect::DealDamage { 
                amount: 2,
                target: TargetType::Player(defender_entity),
            }
        },
    )).id();
    
    let creature2 = app.world.spawn((
        Creature { power: 3, toughness: 3 },
        Blocking { blocked_attackers: vec![creature1] },
        Health { current: 3, maximum: 3 },
        DiesTrigger { 
            effect: TriggerEffect::GainLife { 
                amount: 2,
                target: TargetType::Player(controller_entity),
            }
        },
    )).id();
    
    let defender_entity = app.world.spawn((
        Player {},
        Health { current: 20, maximum: 20 },
    )).id();
    
    let controller_entity = app.world.spawn((
        Player {},
        Health { current: 20, maximum: 20 },
    )).id();
    
    // Process combat damage
    app.world.resource_mut::<TurnManager>().current_phase = Phase::Combat(CombatStep::CombatDamage);
    app.update();
    
    // Check state-based actions
    app.update();
    
    // Process death triggers
    app.update();
    
    // Verify both creatures died
    assert!(app.world.get::<Health>(creature1).is_none());
    assert!(app.world.get::<Health>(creature2).is_none());
    
    // Verify both death triggers resolved
    let defender_health = app.world.get::<Health>(defender_entity).unwrap();
    assert_eq!(defender_health.current, 18); // 20 - 2 from death trigger
    
    let controller_health = app.world.get::<Health>(controller_entity).unwrap();
    assert_eq!(controller_health.current, 22); // 20 + 2 from death trigger
}
}

Test Case: Replacement Effects During Combat

Test: Damage Prevention/Redirection

#![allow(unused)]
fn main() {
#[test]
fn test_damage_replacement_effects() {
    // Test setup
    let mut app = App::new();
    app.add_plugins(MinimalPlugins)
       .add_systems(Update, (combat_damage_system, replacement_effects_system));
       
    // Create attacker and blocker
    let attacker = app.world.spawn((
        Creature { power: 5, toughness: 5 },
        Attacking { defending: defender_entity },
    )).id();
    
    let blocker = app.world.spawn((
        Creature { power: 3, toughness: 3 },
        Blocking { blocked_attackers: vec![attacker] },
        Health { current: 3, maximum: 3 },
        // Replacement effect: Redirect half of all damage to controller (rounded up)
        DamageReplacementEffect { 
            effect_type: ReplacementType::Redirect { 
                percentage: 50,
                target: TargetType::Player(controller_entity),
                round_up: true,
            }
        },
    )).id();
    
    let defender_entity = app.world.spawn((
        Player {},
        Health { current: 20, maximum: 20 },
    )).id();
    
    let controller_entity = app.world.spawn((
        Player {},
        Health { current: 20, maximum: 20 },
    )).id();
    
    // Process combat damage
    app.world.resource_mut::<TurnManager>().current_phase = Phase::Combat(CombatStep::CombatDamage);
    app.update();
    
    // Verify damage was redirected
    let blocker_health = app.world.get::<Health>(blocker).unwrap();
    assert_eq!(blocker_health.current, 1); // 3 - (5 - 3) = 1
    
    let controller_health = app.world.get::<Health>(controller_entity).unwrap();
    assert_eq!(controller_health.current, 17); // 20 - 3 (redirected damage) = 17
    
    // Verify attacker also took damage
    let attacker_health = app.world.get::<Health>(attacker).unwrap();
    assert_eq!(attacker_health.current, 2); // 5 - 3 = 2
}
}

Test Case: Layer-Dependent Effects

Test: Characteristic-Defining Abilities and Pump Spells

#![allow(unused)]
fn main() {
#[test]
fn test_layer_dependent_effects() {
    // Test setup
    let mut app = App::new();
    app.add_plugins(MinimalPlugins)
       .add_systems(Update, (spell_resolution_system, characteristic_layer_system));
       
    // Create creature with CDA (power/toughness equal to cards in hand)
    let creature = app.world.spawn((
        CreatureCard {},
        // Base power/toughness are handled in layer 7a
        CharacteristicDefiningAbility { 
            affects: Characteristic::PowerToughness,
            calculation: Box::new(|world, entity| {
                // We'll mock this for the test - cards in hand = 4
                let cards_in_hand = 4;
                (cards_in_hand, cards_in_hand)
            }),
        },
    )).id();
    
    // Process CDA to establish base power/toughness
    app.update();
    
    // Verify creature has power/toughness equal to cards in hand
    let creature_stats = app.world.get::<Creature>(creature).unwrap();
    assert_eq!(creature_stats.power, 4);
    assert_eq!(creature_stats.toughness, 4);
    
    // Cast pump spell (+2/+2) - this applies in layer 7c
    app.world.spawn((
        PumpSpell { 
            target: creature,
            power_bonus: 2,
            toughness_bonus: 2,
            duration: SpellDuration::EndOfTurn,
        },
        SpellOnStack {},
    ));
    
    // Resolve spell
    app.update();
    
    // Verify creature stats were boosted correctly
    let boosted_creature = app.world.get::<Creature>(creature).unwrap();
    assert_eq!(boosted_creature.power, 6);  // 4 + 2 = 6
    assert_eq!(boosted_creature.toughness, 6);  // 4 + 2 = 6
    
    // Discard a card - affects layer 7a CDA
    app.world.spawn(DiscardCardCommand {
        player: Entity::PLACEHOLDER,
        count: 1,
    });
    app.update();
    
    // Verify stats update correctly (CDA now sees 3 cards, then +2/+2 applies)
    let updated_creature = app.world.get::<Creature>(creature).unwrap();
    assert_eq!(updated_creature.power, 5);  // 3 + 2 = 5
    assert_eq!(updated_creature.toughness, 5);  // 3 + 2 = 5
}
}

Test Case: Changing Controllers During Combat

Test: Creature Changes Controller During Combat

#![allow(unused)]
fn main() {
#[test]
fn test_creature_changes_controller_during_combat() {
    // Test setup
    let mut app = App::new();
    app.add_plugins(MinimalPlugins)
       .add_systems(Update, (combat_damage_system, spell_resolution_system, controller_system));
       
    let player1 = app.world.spawn(Player {}).id();
    let player2 = app.world.spawn(Player {}).id();
    
    // Create attacking creature controlled by player 1
    let attacker = app.world.spawn((
        Creature { power: 3, toughness: 3 },
        Attacking { defending: player2 },
        CreatureCard { controller: player1 },
    )).id();
    
    // Cast control-changing spell before damage
    app.world.spawn((
        ControlChangeSpell { 
            target: attacker,
            new_controller: player2,
            duration: SpellDuration::EndOfTurn,
        },
        SpellOnStack {},
    ));
    
    // Resolve spell
    app.update();
    
    // Verify creature changed controllers
    let creature_card = app.world.get::<CreatureCard>(attacker).unwrap();
    assert_eq!(creature_card.controller, player2);
    
    // Process combat damage
    app.world.resource_mut::<TurnManager>().current_phase = Phase::Combat(CombatStep::CombatDamage);
    app.update();
    
    // Verify creature is still attacking the same player
    assert!(app.world.get::<Attacking>(attacker).is_some());
    assert_eq!(app.world.get::<Attacking>(attacker).unwrap().defending, player2);
    
    // Verify no damage was dealt (can't attack yourself)
    let player2_health = app.world.get::<Health>(player2).unwrap();
    assert_eq!(player2_health.current, player2_health.maximum);
}
}

Test Case: Triggered Abilities Affecting Combat

Test: Attack Triggers Modifying Other Creatures

#![allow(unused)]
fn main() {
#[test]
fn test_attack_trigger_modifying_combat() {
    // Test setup
    let mut app = App::new();
    app.add_plugins(MinimalPlugins)
       .add_systems(Update, (declare_attackers_system, trigger_system, combat_damage_system));
       
    let player1 = app.world.spawn(Player {}).id();
    let player2 = app.world.spawn(Player {
        health: Health { current: 20, maximum: 20 },
    }).id();
    
    // Create creature with attack trigger
    let battle_leader = app.world.spawn((
        Creature { power: 2, toughness: 2 },
        CreatureCard { controller: player1 },
        AttackTrigger { 
            effect: TriggerEffect::PumpCreatures { 
                target_filter: TargetFilter::Controlled { controller: player1 },
                power_bonus: 1,
                toughness_bonus: 1,
                duration: SpellDuration::EndOfTurn,
            }
        },
    )).id();
    
    // Create another creature that will benefit from the trigger
    let other_attacker = app.world.spawn((
        Creature { power: 2, toughness: 2 },
        CreatureCard { controller: player1 },
    )).id();
    
    // Declare attackers
    app.world.entity_mut(battle_leader).insert(Attacking { defending: player2 });
    app.world.entity_mut(other_attacker).insert(Attacking { defending: player2 });
    
    // Process attack triggers
    app.world.resource_mut::<TurnManager>().current_phase = Phase::Combat(CombatStep::DeclareAttackers);
    app.update();
    
    // Verify both creatures were boosted
    let battle_leader_stats = app.world.get::<Creature>(battle_leader).unwrap();
    assert_eq!(battle_leader_stats.power, 3);  // 2 + 1 = 3
    
    let other_attacker_stats = app.world.get::<Creature>(other_attacker).unwrap();
    assert_eq!(other_attacker_stats.power, 3);  // 2 + 1 = 3
    
    // Process combat damage
    app.world.resource_mut::<TurnManager>().current_phase = Phase::Combat(CombatStep::CombatDamage);
    app.update();
    
    // Verify damage with the boost
    let player2_health = app.world.get::<Health>(player2).unwrap();
    assert_eq!(player2_health.current, 14);  // 20 - 3 - 3 = 14
}
}

Test Case: Damage Doubling and Prevention Interactions

Test: Damage Doubling with Prevention Shield

#![allow(unused)]
fn main() {
#[test]
fn test_damage_doubling_with_prevention() {
    // Test setup
    let mut app = App::new();
    app.add_plugins(MinimalPlugins)
       .add_systems(Update, (combat_damage_system, replacement_effects_system));
       
    // Create attacker with damage doubling effect
    let attacker = app.world.spawn((
        Creature { power: 4, toughness: 4 },
        Attacking { defending: defender_entity },
        DamageReplacementEffect { 
            effect_type: ReplacementType::Double { 
                condition: DamageCondition::DealingCombatDamage,
            }
        },
    )).id();
    
    // Create defender with protection shield
    let defender_entity = app.world.spawn((
        Player {},
        Health { current: 20, maximum: 20 },
        PreventDamage {
            amount: 5,
            source_filter: None,
        },
    )).id();
    
    // Process combat damage
    app.world.resource_mut::<TurnManager>().current_phase = Phase::Combat(CombatStep::CombatDamage);
    app.update();
    
    // The original 4 damage is doubled to 8, then 5 is prevented
    let defender_health = app.world.get::<Health>(defender_entity).unwrap();
    assert_eq!(defender_health.current, 17);  // 20 - (8 - 5) = 17
}
}

Test Case: Indestructible in Combat

Test: Indestructible Creature with Lethal Damage

#![allow(unused)]
fn main() {
#[test]
fn test_indestructible_with_lethal_damage() {
    // Test setup
    let mut app = App::new();
    app.add_plugins(MinimalPlugins)
       .add_systems(Update, (combat_damage_system, state_based_actions_system));
       
    // Create indestructible attacker
    let indestructible_attacker = app.world.spawn((
        Creature { power: 4, toughness: 4 },
        Attacking { defending: defender_entity },
        Health { current: 4, maximum: 4 },
        Indestructible {},
    )).id();
    
    // Create blocker with deathtouch
    let deathtouch_blocker = app.world.spawn((
        Creature { power: 1, toughness: 1 },
        Blocking { blocked_attackers: vec![indestructible_attacker] },
        Health { current: 1, maximum: 1 },
        Deathtouch {},
    )).id();
    
    let defender_entity = app.world.spawn((
        Player {},
        Health { current: 20, maximum: 20 },
    )).id();
    
    // Process combat damage
    app.world.resource_mut::<TurnManager>().current_phase = Phase::Combat(CombatStep::CombatDamage);
    app.update();
    
    // Check state-based actions
    app.update();
    
    // Verify blocker died
    assert!(app.world.get::<Health>(deathtouch_blocker).is_none());
    
    // Verify indestructible attacker is damaged but alive
    let attacker_health = app.world.get::<Health>(indestructible_attacker).unwrap();
    assert_eq!(attacker_health.current, 3);  // 4 - 1 = 3
    assert!(app.world.entity(indestructible_attacker).contains::<Indestructible>());
    
    // Verify it can still be in further combats despite lethal deathtouch damage
    let attacker_health = app.world.get::<Health>(indestructible_attacker).unwrap();
    app.world.entity_mut(indestructible_attacker).insert(
        DamageReceived {
            amount: 10,
            source: deathtouch_blocker,
            is_combat_damage: true,
        }
    );
    
    // Process more damage
    app.update();
    
    // Check state-based actions
    app.update();
    
    // Verify indestructible creature is still alive despite lethal damage
    assert!(app.world.get::<Health>(indestructible_attacker).is_some());
}
}

Test Case: Phasing and Pump Spell Interactions

Test: Phasing Out After Pump Spell

#![allow(unused)]
fn main() {
#[test]
fn test_phasing_after_pump_spell() {
    // Test setup
    let mut app = App::new();
    app.add_plugins(MinimalPlugins)
       .add_systems(Update, (spell_resolution_system, phasing_system, end_of_turn_system));
       
    // Create creature
    let creature = app.world.spawn((
        Creature { power: 2, toughness: 2 },
        CreatureCard {},
    )).id();
    
    // Cast pump spell
    app.world.spawn((
        PumpSpell { 
            target: creature,
            power_bonus: 2,
            toughness_bonus: 2,
            duration: SpellDuration::EndOfTurn,
        },
        SpellOnStack {},
    ));
    
    // Resolve spell
    app.update();
    
    // Verify creature stats were boosted
    let boosted_creature = app.world.get::<Creature>(creature).unwrap();
    assert_eq!(boosted_creature.power, 4);
    assert_eq!(boosted_creature.toughness, 4);
    
    // Phase out the creature
    app.world.entity_mut(creature).insert(PhasedOut {});
    
    // Process end of turn
    app.world.resource_mut::<TurnManager>().current_phase = Phase::End(EndPhaseStep::End);
    app.update();
    
    // The creature is phased out, so end of turn effects shouldn't affect it
    
    // Phase the creature back in
    app.world.entity_mut(creature).remove::<PhasedOut>();
    
    // Now the pump effect should still be active (since it didn't wear off while phased out)
    let still_boosted = app.world.get::<Creature>(creature).unwrap();
    assert_eq!(still_boosted.power, 4);
    assert_eq!(still_boosted.toughness, 4);
    
    // Process another end of turn
    app.update();
    
    // Now the effect should expire
    let end_of_turn = app.world.get::<Creature>(creature).unwrap();
    assert_eq!(end_of_turn.power, 2);
    assert_eq!(end_of_turn.toughness, 2);
}
}

Performance Considerations for Edge Case Tests

  1. Efficient Interaction Processing: Ensure complex interaction processing is optimized.

  2. Minimize Redundant Query Operations: Structure queries to minimize redundant operations.

  3. Layer System Optimization: Efficiently process layer-dependent effects in the correct order.

Test Coverage Checklist

  • Multiple death triggers during combat
  • Damage replacement/redirection effects
  • Layer-dependent ability interactions
  • Controller change during combat
  • Attack triggers modifying other creatures
  • Damage doubling with prevention
  • Indestructible creatures with lethal damage
  • Phasing and pump spell interactions

Additional Considerations

  1. Tests should verify both immediate effects and downstream consequences
  2. Multiple mechanics should be tested in combination to ensure correct interactions
  3. Order-dependent effects should be tested with different sequencing
  4. Extreme edge cases should be included to stress-test the system

Combat Phasing Tests

This section contains tests for phasing effects during combat in the Commander format.

Tests

Phasing During Combat

Tests for how phasing effects interact with various stages of combat, including:

  • Attackers phasing out after being declared
  • Blockers phasing out after being declared
  • Permanents with combat-relevant abilities phasing out
  • Phasing effects on auras and equipment attached to creatures in combat
  • Phasing and combat damage
  • Phasing interaction with combat triggers

These tests ensure that the complex rules interactions between phasing and combat are handled correctly within the game engine.

Phasing During Combat - Test Cases

Overview

Phasing is a complex mechanic where permanents temporarily leave and re-enter the game without triggering "leaves the battlefield" or "enters the battlefield" effects. When a creature phases out during combat, several edge cases can occur that require special handling.

This document outlines test cases for phasing interactions during combat in our Commander engine.

Test Case: Attacker Phases Out

Test: Attacking Creature Phases Out During Declare Attackers

#![allow(unused)]
fn main() {
#[test]
fn test_attacker_phases_out_after_declare_attackers() {
    // Test setup
    let mut app = App::new();
    app.add_plugins(MinimalPlugins)
       .add_systems(Update, (declare_attackers_system, phasing_system));
       
    // Create attacker and defending player
    let attacker = app.world.spawn((
        Creature { power: 3, toughness: 3, },
        Attacking { defending: defender_entity },
    )).id();
    
    let defender_entity = app.world.spawn((
        Player {},
        Health { current: 20, maximum: 20 },
    )).id();
    
    // Phase out the attacker
    app.world.spawn(PhaseOutCommand { target: attacker });
    
    // Process phasing
    app.update();
    
    // Verify the creature is phased out but still marked as attacking
    assert!(app.world.get::<PhasedOut>(attacker).is_some());
    assert!(app.world.get::<Attacking>(attacker).is_some());
    
    // When combat damage is processed, it should not deal damage
    app.world.resource_mut::<TurnManager>().current_phase = Phase::Combat(CombatStep::CombatDamage);
    app.update();
    
    // Verify no damage was dealt to defender
    let health = app.world.get::<Health>(defender_entity).unwrap();
    assert_eq!(health.current, 20);
}
}

Test: Phased Out Attacker at End of Combat

#![allow(unused)]
fn main() {
#[test]
fn test_phased_out_attacker_at_end_of_combat() {
    // Test setup
    let mut app = App::new();
    app.add_plugins(MinimalPlugins)
       .add_systems(Update, (end_of_combat_system, phasing_system));
       
    // Create phased out attacker
    let attacker = app.world.spawn((
        Creature { power: 3, toughness: 3, },
        Attacking { defending: defender_entity },
        PhasedOut {},
    )).id();
    
    // Process end of combat
    app.world.resource_mut::<TurnManager>().current_phase = Phase::Combat(CombatStep::End);
    app.world.resource_mut::<CombatSystem>().end_of_combat.triggers_processed = true;
    app.update();
    
    // Verify attacking status is removed, even though creature is phased out
    assert!(!app.world.entity(attacker).contains::<Attacking>());
}
}

Test Case: Blocker Phases Out

Test: Blocking Creature Phases Out During Declare Blockers

#![allow(unused)]
fn main() {
#[test]
fn test_blocker_phases_out_after_declare_blockers() {
    // Test setup
    let mut app = App::new();
    app.add_plugins(MinimalPlugins)
       .add_systems(Update, (declare_blockers_system, phasing_system, combat_damage_system));
       
    // Create attacker, blocker, and player
    let attacker = app.world.spawn((
        Creature { power: 3, toughness: 3, },
        Attacking { defending: defender_entity },
    )).id();
    
    let blocker = app.world.spawn((
        Creature { power: 2, toughness: 2, },
        Blocking { blocked_attackers: vec![attacker] },
    )).id();
    
    let defender_entity = app.world.spawn((
        Player {},
        Health { current: 20, maximum: 20 },
    )).id();
    
    // Phase out the blocker
    app.world.spawn(PhaseOutCommand { target: blocker });
    
    // Process phasing
    app.update();
    
    // Verify the creature is phased out but still marked as blocking
    assert!(app.world.get::<PhasedOut>(blocker).is_some());
    assert!(app.world.get::<Blocking>(blocker).is_some());
    
    // When combat damage is processed, attacker should deal damage to player
    // since phased out blocker cannot block
    app.world.resource_mut::<TurnManager>().current_phase = Phase::Combat(CombatStep::CombatDamage);
    app.update();
    
    // Verify damage was dealt to defender (attacker was effectively unblocked)
    let health = app.world.get::<Health>(defender_entity).unwrap();
    assert_eq!(health.current, 17);
}
}

Test Case: Phasing During Combat Damage

Test: Creature Phases Out in Response to Damage

#![allow(unused)]
fn main() {
#[test]
fn test_creature_phases_out_in_response_to_damage() {
    // Test setup
    let mut app = App::new();
    app.add_plugins(MinimalPlugins)
       .add_systems(Update, (combat_damage_system, phasing_system));
       
    // Create attacker and blocker
    let attacker = app.world.spawn((
        Creature { power: 3, toughness: 3, },
        Attacking { defending: defender_entity },
    )).id();
    
    let blocker = app.world.spawn((
        Creature { power: 2, toughness: 2, },
        Blocking { blocked_attackers: vec![attacker] },
        Health { current: 2, maximum: 2 },
    )).id();
    
    // Simulate player responding to damage by phasing out the blocker
    // (in a real implementation, this would be triggered by priority passing)
    app.world.spawn(PhaseOutCommand { 
        target: blocker,
        trigger_condition: TriggerCondition::BeforeDamage,
    });
    
    // Process combat damage
    app.world.resource_mut::<TurnManager>().current_phase = Phase::Combat(CombatStep::CombatDamage);
    app.update();
    
    // Verify blocker phased out and did not receive damage
    assert!(app.world.get::<PhasedOut>(blocker).is_some());
    let health = app.world.get::<Health>(blocker).unwrap();
    assert_eq!(health.current, 2); // Health unchanged
    
    // Verify attacker did not receive damage either
    let attacker_health = app.world.get::<Health>(attacker).unwrap();
    assert_eq!(attacker_health.current, attacker_health.maximum);
}
}

Test Case: Phasing In During Combat

Test: Creature Phases In During Combat

#![allow(unused)]
fn main() {
#[test]
fn test_creature_phases_in_during_combat() {
    // Test setup
    let mut app = App::new();
    app.add_plugins(MinimalPlugins)
       .add_systems(Update, (phasing_system, combat_system));
       
    // Create phased out creature
    let creature = app.world.spawn((
        Creature { power: 3, toughness: 3, },
        PhasedOut {},
    )).id();
    
    // Set up phase in trigger for declare blockers step
    app.world.spawn(PhaseInCommand { 
        target: creature,
        trigger_phase: Phase::Combat(CombatStep::DeclareBlockers),
    });
    
    // Advance to declare blockers
    app.world.resource_mut::<TurnManager>().current_phase = Phase::Combat(CombatStep::DeclareBlockers);
    app.update();
    
    // Verify creature phased in
    assert!(app.world.get::<PhasedOut>(creature).is_none());
    
    // Verify creature cannot be declared as a blocker this combat
    // (creatures that phase in are treated as though they just entered the battlefield)
    let can_block = app.world.get_resource::<CombatSystem>().unwrap()
        .can_block(creature, attacker_entity);
    assert!(!can_block);
}
}

Test Case: Phasing with Auras and Equipment

Test: Equipped Creature Phases Out

#![allow(unused)]
fn main() {
#[test]
fn test_equipped_creature_phases_out_during_combat() {
    // Test setup
    let mut app = App::new();
    app.add_plugins(MinimalPlugins)
       .add_systems(Update, (phasing_system, combat_damage_system));
       
    // Create creature with equipment
    let creature = app.world.spawn((
        Creature { power: 2, toughness: 2, },
        Attacking { defending: defender_entity },
    )).id();
    
    let equipment = app.world.spawn((
        Equipment { 
            equipped_to: creature,
            power_bonus: 2,
            toughness_bonus: 0,
        },
        Attached { attached_to: creature },
    )).id();
    
    // Phase out the creature
    app.world.spawn(PhaseOutCommand { target: creature });
    app.update();
    
    // Verify creature and equipment are both phased out
    assert!(app.world.get::<PhasedOut>(creature).is_some());
    assert!(app.world.get::<PhasedOut>(equipment).is_some());
    
    // Process combat damage
    app.world.resource_mut::<TurnManager>().current_phase = Phase::Combat(CombatStep::CombatDamage);
    app.update();
    
    // Verify no damage was dealt
    let health = app.world.get::<Health>(defender_entity).unwrap();
    assert_eq!(health.current, 20);
    
    // Phase in during a later turn
    app.world.spawn(PhaseInCommand { 
        target: creature,
        trigger_phase: Phase::Main(MainPhaseStep::First),
    });
    
    app.world.resource_mut::<TurnManager>().current_phase = Phase::Main(MainPhaseStep::First);
    app.update();
    
    // Verify both creature and equipment phased in and are still attached
    assert!(app.world.get::<PhasedOut>(creature).is_none());
    assert!(app.world.get::<PhasedOut>(equipment).is_none());
    assert_eq!(app.world.get::<Equipment>(equipment).unwrap().equipped_to, creature);
    assert_eq!(app.world.get::<Attached>(equipment).unwrap().attached_to, creature);
}
}

Integration with Turn Structure

Test: Phasing and Turn Phases

#![allow(unused)]
fn main() {
#[test]
fn test_phasing_respects_turn_structure() {
    // Test setup
    let mut app = App::new();
    app.add_plugins(MinimalPlugins)
       .add_systems(Update, (phasing_system, turn_system));
       
    // Create creature that phases out
    let creature = app.world.spawn((
        Creature { power: 3, toughness: 3, },
    )).id();
    
    // Phase out every other turn
    app.world.spawn(RecurringPhaseCommand { 
        target: creature,
        phase_out_on: TurnCondition::ActivePlayerTurn,
        phase_in_on: TurnCondition::NonActivePlayerTurn,
    });
    
    // Set active player
    app.world.resource_mut::<TurnManager>().active_player = player1_entity;
    app.update();
    
    // Verify creature is phased out on active player's turn
    assert!(app.world.get::<PhasedOut>(creature).is_some());
    
    // Change active player
    app.world.resource_mut::<TurnManager>().active_player = player2_entity;
    app.update();
    
    // Verify creature phases in on non-active player's turn
    assert!(app.world.get::<PhasedOut>(creature).is_none());
}
}

Performance Considerations for Phasing Tests

  1. Batch Phasing Operations: Group phasing operations to minimize entity access.

  2. Efficient Phase Tracking: Use a more efficient system for tracking phased entities.

  3. Minimize Query Iterations: Structure queries to minimize iterations through phased entities.

Test Coverage Checklist

  • Attackers phasing out during declare attackers
  • Attackers phasing out during combat damage
  • Blockers phasing out during declare blockers
  • Blockers phasing out during combat damage
  • Creatures phasing in during combat
  • End of combat cleanup with phased entities
  • Phasing with attached permanents (equipment, auras)
  • Turn structure integration with phasing

Additional Edge Cases to Consider

  1. Multiple creatures phasing in/out simultaneously
  2. Phasing commander in and out (commander damage tracking)
  3. Phasing and "enters the battlefield" replacement effects
  4. Phasing and state-based actions

Combat Pump Spell Tests

This section contains tests for power and toughness modifying effects (pump spells) during combat in the Commander format.

Tests

Combat Pumps

Tests for how pump spells and similar effects interact with combat, including:

  • Instant-speed pump spells after blockers are declared
  • Pump effects that grant additional abilities (like first strike)
  • Timing of pump effects and their impact on combat damage
  • Interaction between multiple pump effects
  • Pump effects that last until end of turn versus those with permanent effects
  • Combat tricks that affect multiple creatures simultaneously

These tests ensure that power/toughness modification during combat is handled correctly according to the rules and timing of Magic: The Gathering.

Combat Pump Spells - Test Cases

Overview

Combat pump spells are instants that temporarily boost a creature's power and/or toughness, often used during combat to alter the outcome. These spells can be cast at various points during the combat phase, leading to different results depending on timing.

This document outlines test cases for combat pump spell interactions in our Commander engine.

Test Case: Basic Pump Spell Effects

Test: Power/Toughness Boost During Declare Blockers

#![allow(unused)]
fn main() {
#[test]
fn test_pump_spell_during_declare_blockers() {
    // Test setup
    let mut app = App::new();
    app.add_plugins(MinimalPlugins)
       .add_systems(Update, (declare_blockers_system, spell_resolution_system));
       
    // Create attacker and potential blocker
    let attacker = app.world.spawn((
        Creature { power: 4, toughness: 4 },
        Attacking { defending: defender_entity },
    )).id();
    
    let small_blocker = app.world.spawn((
        Creature { power: 1, toughness: 1 },
        CreatureCard { controller: defender_entity },
    )).id();
    
    let defender_entity = app.world.spawn((
        Player {},
        Health { current: 20, maximum: 20 },
    )).id();
    
    // Set up initially - blocker cannot block due to being too small
    app.world.resource_mut::<CombatSystem>().can_block_check = |blocker, attacker| {
        let blocker_creature = app.world.get::<Creature>(blocker).unwrap();
        let attacker_creature = app.world.get::<Creature>(attacker).unwrap();
        blocker_creature.power >= attacker_creature.power / 2
    };
    
    // Can't block initially
    let can_block_initially = app.world.resource::<CombatSystem>()
        .can_block(small_blocker, attacker);
    assert!(!can_block_initially);
    
    // Cast pump spell (+3/+3 until end of turn)
    app.world.spawn((
        PumpSpell { 
            target: small_blocker,
            power_bonus: 3,
            toughness_bonus: 3,
            duration: SpellDuration::EndOfTurn,
        },
        SpellOnStack {},
    ));
    
    // Resolve spell
    app.update();
    
    // Verify creature stats were boosted
    let boosted_creature = app.world.get::<Creature>(small_blocker).unwrap();
    assert_eq!(boosted_creature.power, 4);
    assert_eq!(boosted_creature.toughness, 4);
    
    // Now should be able to block
    let can_block_after = app.world.resource::<CombatSystem>()
        .can_block(small_blocker, attacker);
    assert!(can_block_after);
}
}

Test Case: Pump Spells During Combat Damage

Test: Pump Spell Saving Creature from Lethal Damage

#![allow(unused)]
fn main() {
#[test]
fn test_pump_spell_prevents_lethal_damage() {
    // Test setup
    let mut app = App::new();
    app.add_plugins(MinimalPlugins)
       .add_systems(Update, (combat_damage_system, spell_resolution_system));
       
    // Create attacker and blocker
    let attacker = app.world.spawn((
        Creature { power: 3, toughness: 3 },
        Attacking { defending: defender_entity },
    )).id();
    
    let blocker = app.world.spawn((
        Creature { power: 2, toughness: 2 },
        Blocking { blocked_attackers: vec![attacker] },
        Health { current: 2, maximum: 2 },
    )).id();
    
    // Before damage is dealt, cast pump spell to increase toughness
    // In a real implementation, this would be during the priority window
    app.world.spawn((
        PumpSpell { 
            target: blocker,
            power_bonus: 0,
            toughness_bonus: 2,
            duration: SpellDuration::EndOfTurn,
        },
        SpellOnStack {},
    ));
    
    // Resolve spell
    app.update();
    
    // Verify creature stats were boosted
    let boosted_creature = app.world.get::<Creature>(blocker).unwrap();
    assert_eq!(boosted_creature.power, 2);
    assert_eq!(boosted_creature.toughness, 4);
    
    // Process combat damage
    app.world.resource_mut::<TurnManager>().current_phase = Phase::Combat(CombatStep::CombatDamage);
    app.update();
    
    // Verify blocker survived
    let health = app.world.get::<Health>(blocker).unwrap();
    assert!(health.current > 0);
}
}

Test: Increasing Power After Blockers to Deal More Damage

#![allow(unused)]
fn main() {
#[test]
fn test_pump_spell_increases_damage_after_blockers() {
    // Test setup
    let mut app = App::new();
    app.add_plugins(MinimalPlugins)
       .add_systems(Update, (combat_damage_system, spell_resolution_system));
       
    // Create attacker and defending player
    let attacker = app.world.spawn((
        Creature { power: 2, toughness: 2 },
        Attacking { defending: defender_entity },
    )).id();
    
    let defender_entity = app.world.spawn((
        Player {},
        Health { current: 20, maximum: 20 },
    )).id();
    
    // After declare blockers, cast pump spell to increase power
    app.world.spawn((
        PumpSpell { 
            target: attacker,
            power_bonus: 3,
            toughness_bonus: 0,
            duration: SpellDuration::EndOfTurn,
        },
        SpellOnStack {},
    ));
    
    // Resolve spell
    app.update();
    
    // Verify creature stats were boosted
    let boosted_creature = app.world.get::<Creature>(attacker).unwrap();
    assert_eq!(boosted_creature.power, 5);
    
    // Process combat damage
    app.world.resource_mut::<TurnManager>().current_phase = Phase::Combat(CombatStep::CombatDamage);
    app.update();
    
    // Verify more damage was dealt to player
    let health = app.world.get::<Health>(defender_entity).unwrap();
    assert_eq!(health.current, 15); // 20 - 5 = 15
}
}

Test Case: Timing Edge Cases

Test: Pump Spell During First Strike Damage

#![allow(unused)]
fn main() {
#[test]
fn test_pump_spell_between_first_strike_and_normal_damage() {
    // Test setup
    let mut app = App::new();
    app.add_plugins(MinimalPlugins)
       .add_systems(Update, (first_strike_damage_system, combat_damage_system, spell_resolution_system));
       
    // Create first strike attacker and regular blocker
    let first_strike_attacker = app.world.spawn((
        Creature { power: 2, toughness: 2 },
        Attacking { defending: defender_entity },
        FirstStrike {},
    )).id();
    
    let regular_blocker = app.world.spawn((
        Creature { power: 2, toughness: 2 },
        Blocking { blocked_attackers: vec![first_strike_attacker] },
        Health { current: 2, maximum: 2 },
    )).id();
    
    // Process first strike damage
    app.world.resource_mut::<TurnManager>().current_phase = Phase::Combat(CombatStep::FirstStrike);
    app.update();
    
    // Verify blocker took damage but is still alive
    let health_after_first_strike = app.world.get::<Health>(regular_blocker).unwrap();
    assert_eq!(health_after_first_strike.current, 0);
    
    // SBA would normally destroy the creature here, but we'll simulate a pump spell in response
    // Cast Sudden Invigoration (instant that gives +0/+2 and prevents damage this turn)
    app.world.spawn((
        PumpSpell { 
            target: regular_blocker,
            power_bonus: 0,
            toughness_bonus: 2,
            duration: SpellDuration::EndOfTurn,
        },
        SpellOnStack {},
        PreventDamageEffect { amount: 2 },
    ));
    
    // Resolve spell and apply effects
    app.update();
    
    // Reset health as if the damage had been prevented
    app.world.entity_mut(regular_blocker).insert(Health { current: 2, maximum: 2 });
    
    // Verify creature stats were boosted and creature is alive
    let boosted_creature = app.world.get::<Creature>(regular_blocker).unwrap();
    assert_eq!(boosted_creature.toughness, 4);
    
    let health_after_spell = app.world.get::<Health>(regular_blocker).unwrap();
    assert_eq!(health_after_spell.current, 2);
    
    // Process regular combat damage
    app.world.resource_mut::<TurnManager>().current_phase = Phase::Combat(CombatStep::CombatDamage);
    app.update();
    
    // Verify blocker survived regular combat damage too
    let final_health = app.world.get::<Health>(regular_blocker).unwrap();
    assert_eq!(final_health.current, 2); // Damage was prevented
}
}

Test: Pump Spell at End of Combat

#![allow(unused)]
fn main() {
#[test]
fn test_pump_spell_at_end_of_combat() {
    // Test setup
    let mut app = App::new();
    app.add_plugins(MinimalPlugins)
       .add_systems(Update, (end_of_combat_system, spell_resolution_system));
       
    // Create creature
    let creature = app.world.spawn((
        Creature { power: 2, toughness: 2 },
        CreatureCard {},
    )).id();
    
    // Cast pump spell with "until end of turn" duration
    app.world.spawn((
        PumpSpell { 
            target: creature,
            power_bonus: 3,
            toughness_bonus: 3,
            duration: SpellDuration::EndOfTurn,
        },
        SpellOnStack {},
    ));
    
    // Resolve spell
    app.update();
    
    // Verify creature stats were boosted
    let boosted_creature = app.world.get::<Creature>(creature).unwrap();
    assert_eq!(boosted_creature.power, 5);
    assert_eq!(boosted_creature.toughness, 5);
    
    // Add EndOfTurn effect
    app.world.entity_mut(creature).insert(UntilEndOfTurn {
        original_power: 2,
        original_toughness: 2,
    });
    
    // Process end of combat phase
    app.world.resource_mut::<TurnManager>().current_phase = Phase::Combat(CombatStep::End);
    app.world.resource_mut::<CombatSystem>().end_of_combat.triggers_processed = true;
    app.update();
    
    // Verify creature stats still boosted after end of combat
    // (effects last until end of turn, not end of combat)
    let still_boosted = app.world.get::<Creature>(creature).unwrap();
    assert_eq!(still_boosted.power, 5);
    assert_eq!(still_boosted.toughness, 5);
    
    // Process end of turn
    app.world.resource_mut::<TurnManager>().current_phase = Phase::End(EndPhaseStep::End);
    app.update();
    
    // Now the effects should wear off
    let end_of_turn = app.world.get::<Creature>(creature).unwrap();
    assert_eq!(end_of_turn.power, 2);
    assert_eq!(end_of_turn.toughness, 2);
}
}

Test Case: Multiple Pump Spells

Test: Stacking Pump Effects

#![allow(unused)]
fn main() {
#[test]
fn test_stacking_pump_spells() {
    // Test setup
    let mut app = App::new();
    app.add_plugins(MinimalPlugins)
       .add_systems(Update, spell_resolution_system);
       
    // Create creature
    let creature = app.world.spawn((
        Creature { power: 1, toughness: 1 },
        CreatureCard {},
    )).id();
    
    // Cast first pump spell
    app.world.spawn((
        PumpSpell { 
            target: creature,
            power_bonus: 2,
            toughness_bonus: 2,
            duration: SpellDuration::EndOfTurn,
        },
        SpellOnStack {},
    ));
    
    // Resolve first spell
    app.update();
    
    // Cast second pump spell
    app.world.spawn((
        PumpSpell { 
            target: creature,
            power_bonus: 1,
            toughness_bonus: 3,
            duration: SpellDuration::EndOfTurn,
        },
        SpellOnStack {},
    ));
    
    // Resolve second spell
    app.update();
    
    // Verify creature stats were properly stacked
    let boosted_creature = app.world.get::<Creature>(creature).unwrap();
    assert_eq!(boosted_creature.power, 4);  // 1 + 2 + 1 = 4
    assert_eq!(boosted_creature.toughness, 6);  // 1 + 2 + 3 = 6
}
}

Test Case: Pump Spells and Combat Abilities

Test: Pump Giving Trample

#![allow(unused)]
fn main() {
#[test]
fn test_pump_spell_gives_trample() {
    // Test setup
    let mut app = App::new();
    app.add_plugins(MinimalPlugins)
       .add_systems(Update, (combat_damage_system, spell_resolution_system));
       
    // Create attacker, blocker, and player
    let attacker = app.world.spawn((
        Creature { power: 4, toughness: 4 },
        Attacking { defending: defender_entity },
    )).id();
    
    let blocker = app.world.spawn((
        Creature { power: 2, toughness: 2 },
        Blocking { blocked_attackers: vec![attacker] },
        Health { current: 2, maximum: 2 },
    )).id();
    
    let defender_entity = app.world.spawn((
        Player {},
        Health { current: 20, maximum: 20 },
    )).id();
    
    // Cast pump spell that also grants trample
    app.world.spawn((
        PumpSpell { 
            target: attacker,
            power_bonus: 1,
            toughness_bonus: 1,
            duration: SpellDuration::EndOfTurn,
        },
        SpellOnStack {},
        GrantAbilityEffect { 
            ability: CreatureAbility::Trample,
            duration: SpellDuration::EndOfTurn,
        },
    ));
    
    // Resolve spell
    app.update();
    
    // Verify creature stats were boosted and gained trample
    let boosted_creature = app.world.get::<Creature>(attacker).unwrap();
    assert_eq!(boosted_creature.power, 5);
    assert!(app.world.get::<Trample>(attacker).is_some());
    
    // Process combat damage
    app.world.resource_mut::<TurnManager>().current_phase = Phase::Combat(CombatStep::CombatDamage);
    app.update();
    
    // Verify blocker is destroyed
    let blocker_health = app.world.get::<Health>(blocker).unwrap();
    assert_eq!(blocker_health.current, 0);
    
    // Verify excess damage trampled through to player
    let player_health = app.world.get::<Health>(defender_entity).unwrap();
    assert_eq!(player_health.current, 17); // 20 - (5-2) = 17
}
}

Test Case: Pump Spell Targeting

Test: Incorrectly Targeted Pump Spell

#![allow(unused)]
fn main() {
#[test]
fn test_illegally_targeted_pump_spell() {
    // Test setup
    let mut app = App::new();
    app.add_plugins(MinimalPlugins)
       .add_systems(Update, spell_resolution_system);
       
    // Create creature with hexproof
    let hexproof_creature = app.world.spawn((
        Creature { power: 2, toughness: 2 },
        CreatureCard {},
        Hexproof {},
    )).id();
    
    // Create opposing player
    let opponent_entity = app.world.spawn(Player {}).id();
    
    // Cast pump spell from opponent (should fail due to hexproof)
    app.world.spawn((
        PumpSpell { 
            target: hexproof_creature,
            power_bonus: 2,
            toughness_bonus: 2,
            duration: SpellDuration::EndOfTurn,
        },
        SpellOnStack {},
        SpellController { controller: opponent_entity },
    ));
    
    // Attempt to resolve spell (should be countered by game rules)
    app.update();
    
    // Verify creature stats were not changed
    let creature = app.world.get::<Creature>(hexproof_creature).unwrap();
    assert_eq!(creature.power, 2);
    assert_eq!(creature.toughness, 2);
    
    // Verify spell was countered
    let spell_events = app.world.resource::<Events<SpellEvent>>().get_reader().iter().collect::<Vec<_>>();
    assert!(spell_events.iter().any(|event| matches!(event, SpellEvent::Countered(_))));
}
}

Integration with Combat System

Test: Pump Spells and State-Based Actions

#![allow(unused)]
fn main() {
#[test]
fn test_pump_spell_and_state_based_actions() {
    // Test setup
    let mut app = App::new();
    app.add_plugins(MinimalPlugins)
       .add_systems(Update, (spell_resolution_system, state_based_actions_system));
       
    // Create damaged creature
    let creature = app.world.spawn((
        Creature { power: 2, toughness: 2 },
        CreatureCard {},
        Health { current: 1, maximum: 2 },
    )).id();
    
    // Deal damage to creature
    app.world.entity_mut(creature).insert(DamageReceived {
        amount: 2,
        source: Entity::PLACEHOLDER,
        is_combat_damage: false,
    });
    
    // Process state-based actions to apply damage
    app.update();
    
    // Health would be 0, but don't destroy yet
    
    // Cast pump spell to increase toughness before SBA check
    app.world.spawn((
        PumpSpell { 
            target: creature,
            power_bonus: 0,
            toughness_bonus: 2,
            duration: SpellDuration::EndOfTurn,
        },
        SpellOnStack {},
        InstantEffect {},
    ));
    
    // Resolve spell
    app.update();
    
    // Verify creature stats were boosted
    let boosted_creature = app.world.get::<Creature>(creature).unwrap();
    assert_eq!(boosted_creature.toughness, 4);
    
    // Update health to reflect new toughness (would happen in a real implementation)
    app.world.entity_mut(creature).insert(Health {
        current: 1, // Still has damage marked
        maximum: 4,
    });
    
    // Verify creature is still alive despite the damage
    assert!(app.world.get::<Health>(creature).is_some());
    
    // Process state-based actions again
    app.update();
    
    // Verify creature is still alive
    assert!(app.world.get::<Health>(creature).is_some());
}
}

Performance Considerations for Pump Spell Tests

  1. Optimize Spell Resolution: Structure spell resolution to minimize entity access.

  2. Batch Effect Application: Group similar effects when applying multiple pump spells.

  3. Efficient Stat Calculation: Use an efficient system for calculating final creature stats.

Test Coverage Checklist

  • Basic pump spell application
  • Pump spells preventing lethal damage
  • Increasing power after blockers
  • Pump spells during first strike damage
  • Pump spells at end of combat
  • Stacking multiple pump spells
  • Pump spells granting abilities
  • Invalid pump spell targeting
  • Pump spells and state-based actions
  • Pump spells with delayed effects

Additional Edge Cases to Consider

  1. Pump spells with conditional effects
  2. Pump spells that scale based on game state
  3. Pump spells that trigger other abilities
  4. Temporary control change plus pump effects
  5. Layer-dependent pump effects (timestamps, dependencies)

Special Rules

This section covers special rules and mechanics unique to the Commander format.

Contents

Commander-Specific Rules

The special rules section covers Commander-specific rules that are unique to the format:

Partners and Backgrounds

Partner mechanics allow players to have two commanders. There are several forms:

  • Universal Partners: Any two commanders with "Partner" can be paired
  • Partner With: Specific cards that can only partner with their named counterpart
  • Background: Legendary creatures that "can have a Background" as a second commander
  • Friends Forever: A variant of Partner that allows pairing any two "Friends Forever" cards

See Partner Commanders for implementation details.

Commander Ninjutsu

Commander Ninjutsu is a variant of the Ninjutsu mechanic that allows a commander to be put onto the battlefield from the command zone by returning an unblocked attacker to hand. This mechanic is currently only found on one card: Yuriko, the Tiger's Shadow.

See Commander Ninjutsu for implementation details.

Commander Death and Zone Changes

In Commander, when a commander would change zones, its owner can choose to move it to the command zone instead. Special rules govern how this interacts with "dies" triggers and other zone-change abilities.

See Commander Death Triggers for implementation details.

Commander-Specific Cards

Many cards have been designed specifically for the Commander format, including:

  • Cards that reference the command zone directly
  • Cards with mechanics only found in Commander products (Myriad, Lieutenant, etc.)
  • Cards that affect the Commander tax
  • Cards designed for multiplayer political play

See Commander-Specific Cards for implementation details.

Multiplayer Politics

Commander is often played as a multiplayer format, and certain mechanics are designed for multiplayer interactions:

  • Voting mechanics
  • Deal-making abilities
  • Group effects that affect all players
  • Incentives and deterrents for attacking

See Multiplayer Politics and Politics Testing for implementation details.

Some Commander-specific mechanics interact with other systems:

Testing Special Rules

The tests directory contains test cases for verifying the correct implementation of special Commander rules and edge case handling.

Multiplayer Politics

Overview

The Multiplayer Politics module handles the social and strategic elements unique to multiplayer Commander games. This includes deal-making, alliance formation, temporary agreements, voting mechanics, and other player interactions not codified in the standard Magic rules.

Core Features

The system includes:

  • Deal Making System: Framework for players to propose, accept, and track in-game deals
  • Voting Mechanics: Implementation of cards with Council's Dilemma, Will of the Council, and other voting abilities
  • Threat Assessment: Tools to analyze and display relative threat levels of players
  • Alliance Tracking: Temporary cooperative arrangements between players
  • Table Talk Integration: Support for in-game communication with policy enforcement
  • Goad Mechanics: Implementation of abilities that force creatures to attack

Implementation

The politics system is implemented through several interconnected components:

#![allow(unused)]
fn main() {
#[derive(Component)]
pub struct PoliticsComponent {
    // Current deals, alliances, and political state
    pub active_deals: Vec<Deal>,
    pub alliances: HashMap<Entity, AllianceStrength>,
    pub political_capital: f32,
    pub trust_level: HashMap<Entity, TrustLevel>,
    
    // Historical tracking
    pub broken_deals: Vec<BrokenDeal>,
    pub past_alliances: Vec<PastAlliance>,
}

#[derive(Resource)]
pub struct PoliticsSystem {
    // Global politics configuration
    pub enable_deals: bool,
    pub allow_secret_deals: bool,
    pub deal_enforcement_level: DealEnforcementLevel,
    
    // Event history
    pub political_events: VecDeque<PoliticalEvent>,
}
}

Deal Structure

Deals are structured entities that capture player agreements:

#![allow(unused)]
fn main() {
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Deal {
    /// Unique identifier for the deal
    pub id: Uuid,
    /// Player who proposed the deal
    pub proposer: Entity,
    /// Player(s) who accepted the deal
    pub acceptors: Vec<Entity>,
    /// Terms of the deal - what each party agrees to do
    pub terms: Vec<DealTerm>,
    /// When the deal expires (if temporary)
    pub expiration: Option<DealExpiration>,
    /// Current status of the deal
    pub status: DealStatus,
    /// When the deal was created
    pub created_at: f64,
    /// When the deal was last updated
    pub last_updated: f64,
}

#[derive(Clone, Debug, Serialize, Deserialize)]
pub enum DealTerm {
    /// Promise not to attack a player for N turns
    NonAggression {
        target: Entity,
        duration: u32,
    },
    /// Promise to attack a specific player
    AttackPlayer {
        target: Entity,
        next_turn_only: bool,
    },
    /// Promise not to counter a player's next spell
    NoCounterspell {
        target: Entity,
        duration: u32,
    },
    /// Promise to share resources (e.g. let them draw when you draw)
    ShareResource {
        resource_type: ResourceType,
        target: Entity,
        amount: u32,
    },
    /// Promise to vote with a player on the next vote
    VoteAlignment {
        ally: Entity,
        vote_count: u32,
    },
    /// Custom term with free-form text
    Custom {
        description: String,
    },
}
}

Deal Making

The deal making system allows players to:

  • Propose deals with specific terms and duration
  • Accept or reject deals from other players
  • Set automatic deal conditions and consequences
  • Track deal fulfillment and violations

Deals are non-binding at the rules level but provide framework for player agreements.

#![allow(unused)]
fn main() {
/// System that handles deal proposals
pub fn handle_deal_proposals(
    mut commands: Commands,
    mut deal_events: EventReader<DealProposalEvent>,
    mut deal_response_events: EventWriter<DealResponseEvent>,
    mut politics_components: Query<&mut PoliticsComponent>,
) {
    for event in deal_events.read() {
        if let Ok(mut proposer_politics) = politics_components.get_mut(event.proposer) {
            // Create new deal
            let deal = Deal {
                id: Uuid::new_v4(),
                proposer: event.proposer,
                acceptors: Vec::new(),
                terms: event.terms.clone(),
                expiration: event.expiration.clone(),
                status: DealStatus::Proposed,
                created_at: event.timestamp,
                last_updated: event.timestamp,
            };
            
            // Add to proposer's active deals
            proposer_politics.active_deals.push(deal.clone());
            
            // Notify target players
            for target in &event.targets {
                deal_response_events.send(DealResponseEvent {
                    deal_id: deal.id,
                    response_type: DealResponseType::Offered,
                    player: *target,
                    timestamp: event.timestamp,
                });
            }
        }
    }
}

/// System that handles deal responses
pub fn handle_deal_responses(
    mut commands: Commands,
    mut deal_response_events: EventReader<DealResponseEvent>,
    mut politics_components: Query<&mut PoliticsComponent>,
    mut deal_update_events: EventWriter<DealUpdateEvent>,
) {
    for event in deal_response_events.read() {
        // Handle player responses to deals
        match event.response_type {
            DealResponseType::Accept => {
                // Update the deal status
                for (entity, mut politics) in politics_components.iter_mut() {
                    for deal in &mut politics.active_deals {
                        if deal.id == event.deal_id {
                            deal.acceptors.push(event.player);
                            deal.last_updated = event.timestamp;
                            
                            // If all targets have accepted, activate the deal
                            if deal.acceptors.len() >= deal.terms.len() {
                                deal.status = DealStatus::Active;
                                deal_update_events.send(DealUpdateEvent {
                                    deal_id: deal.id,
                                    new_status: DealStatus::Active,
                                    timestamp: event.timestamp,
                                });
                            }
                            
                            break;
                        }
                    }
                }
            },
            DealResponseType::Reject => {
                // Handle rejection
                for (entity, mut politics) in politics_components.iter_mut() {
                    for deal in &mut politics.active_deals {
                        if deal.id == event.deal_id {
                            deal.status = DealStatus::Rejected;
                            deal.last_updated = event.timestamp;
                            
                            deal_update_events.send(DealUpdateEvent {
                                deal_id: deal.id,
                                new_status: DealStatus::Rejected,
                                timestamp: event.timestamp,
                            });
                            
                            break;
                        }
                    }
                }
            },
            // Handle other response types
            _ => {}
        }
    }
}
}

Voting Mechanics

Many Commander-specific cards feature voting mechanics (Council's Dilemma, Will of the Council). The voting system handles these cards' abilities:

#![allow(unused)]
fn main() {
/// Component for cards with voting mechanics
#[derive(Component)]
pub struct VotingMechanic {
    /// The type of voting mechanic
    pub voting_type: VotingType,
    /// The available options to vote for
    pub options: Vec<String>,
    /// How the results are applied
    pub resolution: VoteResolutionMethod,
}

/// Types of voting mechanics
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum VotingType {
    /// Will of the Council - each player gets one vote
    WillOfCouncil,
    /// Council's Dilemma - each player votes for two different options
    CouncilsDilemma,
    /// Parley - a special voting variant
    Parley,
    /// Custom voting system
    Custom,
}

/// System for handling vote card resolution
pub fn handle_voting_resolution(
    mut commands: Commands,
    mut vote_events: EventReader<VoteCompletionEvent>,
    voting_cards: Query<(Entity, &VotingMechanic)>,
    players: Query<Entity, With<Player>>,
) {
    for event in vote_events.read() {
        if let Ok((card_entity, voting_mechanic)) = voting_cards.get(event.source_card) {
            // Collect and tally votes
            let mut vote_counts: HashMap<String, u32> = HashMap::new();
            
            for (player, vote) in &event.votes {
                *vote_counts.entry(vote.clone()).or_default() += 1;
            }
            
            // Apply effects based on voting results and resolution method
            match voting_mechanic.resolution {
                VoteResolutionMethod::MostVotes => {
                    // Find option with most votes
                    if let Some((winning_option, _)) = vote_counts
                        .iter()
                        .max_by_key(|(_, count)| *count) {
                        
                        // Apply effect for winning option
                        apply_voting_effect(
                            &mut commands, 
                            card_entity, 
                            winning_option, 
                            &event.votes
                        );
                    }
                },
                VoteResolutionMethod::AllVotes => {
                    // Apply effect for each vote
                    for (option, count) in vote_counts {
                        // Apply effect scaled by vote count
                        apply_voting_effect_scaled(
                            &mut commands,
                            card_entity,
                            &option,
                            count,
                            &event.votes
                        );
                    }
                },
                // Handle other resolution methods
                _ => {}
            }
        }
    }
}
}

Example Voting Card Implementation

#![allow(unused)]
fn main() {
/// Implementation of Councils' Judgment
pub fn create_councils_judgment() -> impl Bundle {
    (
        CardName("Council's Judgment".to_string()),
        CardType::Sorcery,
        ManaCost::parse("{1}{W}{W}"),
        VotingMechanic {
            voting_type: VotingType::WillOfCouncil,
            options: vec!["Exile".to_string()], // Dynamic: each nonland permanent becomes an option
            resolution: VoteResolutionMethod::MostVotes,
        },
        CouncilsJudgmentEffect,
    )
}

#[derive(Component)]
pub struct CouncilsJudgmentEffect;

impl ResolveEffect for CouncilsJudgmentEffect {
    fn resolve(&self, world: &mut World, source: Entity, controller: Entity) {
        // Get all permanents as potential targets
        let targets: Vec<Entity> = get_valid_permanent_targets(world);
        
        // Start voting process
        world.send_event(InitiateVoteEvent {
            source: source,
            voting_type: VotingType::WillOfCouncil,
            options: targets.iter().map(|e| get_name_for_entity(world, *e)).collect(),
            initiator: controller,
        });
    }
}
}

Threat Assessment

The threat assessment system helps players evaluate relative threats by:

  • Displaying board state power metrics
  • Tracking win proximity indicators
  • Highlighting potential combo pieces
  • Providing history of player actions and tendencies
#![allow(unused)]
fn main() {
/// Resource for tracking threat assessment
#[derive(Resource)]
pub struct ThreatAssessment {
    /// Calculated threat level for each player
    pub player_threats: HashMap<Entity, ThreatLevel>,
    /// Factors contributing to threat calculation
    pub threat_factors: HashMap<Entity, Vec<ThreatFactor>>,
    /// Historical threat trends
    pub threat_history: HashMap<Entity, VecDeque<HistoricalThreat>>,
}

/// System that updates threat assessment
pub fn update_threat_assessment(
    mut threat_assessment: ResMut<ThreatAssessment>,
    players: Query<Entity, With<Player>>,
    life_totals: Query<&LifeTotal>,
    permanents: Query<(Entity, &Controller)>,
    commanders: Query<(Entity, &Commander, &Controller)>,
    graveyards: Query<(&Graveyard, &Owner)>,
    hands: Query<(&Hand, &Owner)>,
) {
    // Update threat metrics for each player
    for player in players.iter() {
        let mut threat_factors = Vec::new();
        
        // Factor: Board presence
        let board_presence = permanents
            .iter()
            .filter(|(_, controller)| controller.0 == player)
            .count();
        
        threat_factors.push(ThreatFactor {
            factor_type: ThreatFactorType::BoardPresence,
            value: board_presence as f32 * 0.5,
        });
        
        // Factor: Commander damage potential
        if let Some((_, _, _)) = commanders
            .iter()
            .find(|(_, _, controller)| controller.0 == player) {
            // Calculate commander threat...
            threat_factors.push(ThreatFactor {
                factor_type: ThreatFactorType::CommanderPresence,
                value: 5.0, // Base threat for having commander
            });
        }
        
        // Calculate other factors...
        
        // Update total threat
        let total_threat = threat_factors.iter().map(|f| f.value).sum();
        
        threat_assessment.player_threats.insert(player, ThreatLevel(total_threat));
        threat_assessment.threat_factors.insert(player, threat_factors);
        
        // Update history
        threat_assessment.threat_history
            .entry(player)
            .or_default()
            .push_back(HistoricalThreat {
                level: ThreatLevel(total_threat),
                turn: get_current_turn(),
            });
    }
}
}

Goad Mechanics

Goad is a Commander-specific mechanic that forces creatures to attack:

#![allow(unused)]
fn main() {
/// Component for Goad effects
#[derive(Component)]
pub struct Goaded {
    /// The player who applied the goad effect
    pub goaded_by: Entity,
    /// When the goad effect expires
    pub expires_at: ExpiryTiming,
}

/// System that enforces Goad attack requirements
pub fn enforce_goad_attack_requirement(
    goaded_creatures: Query<(Entity, &Goaded, &Controller)>,
    mut attack_requirement_events: EventWriter<AttackRequirementEvent>,
    turn_manager: Res<TurnManager>,
) {
    // Only check during active player's combat
    if turn_manager.current_phase != Phase::Combat(CombatStep::DeclareAttackers) {
        return;
    }
    
    let active_player = turn_manager.active_player;
    
    // Find creatures controlled by active player that are goaded
    for (entity, goaded, controller) in goaded_creatures.iter() {
        if controller.0 == active_player {
            // Creature must attack if able, and cannot attack player who goaded it
            attack_requirement_events.send(AttackRequirementEvent {
                creature: entity,
                must_attack: true,
                cannot_attack: vec![goaded.goaded_by],
            });
        }
    }
}
}

Alliance Tracking

Alliances are temporary arrangements between players:

#![allow(unused)]
fn main() {
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Alliance {
    /// Players in the alliance
    pub members: Vec<Entity>,
    /// Strength/type of alliance
    pub strength: AllianceStrength,
    /// Purpose of the alliance
    pub purpose: String,
    /// When the alliance was formed
    pub formed_at: f64,
    /// When the alliance expires (if temporary)
    pub expires_at: Option<f64>,
}

#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum AllianceStrength {
    /// Weak alliance of convenience
    Weak,
    /// Standard alliance
    Moderate,
    /// Strong alliance with significant shared interests
    Strong,
}

/// System for tracking and updating alliances
pub fn update_alliances(
    mut politics_components: Query<&mut PoliticsComponent>,
    time: Res<Time>,
    mut alliance_events: EventReader<AllianceEvent>,
) {
    let current_time = time.elapsed_seconds_f64();
    
    // Process alliance events
    for event in alliance_events.read() {
        match event.event_type {
            AllianceEventType::Form => {
                // Create new alliance
                let alliance = Alliance {
                    members: event.members.clone(),
                    strength: event.strength.clone(),
                    purpose: event.purpose.clone(),
                    formed_at: current_time,
                    expires_at: event.expiration,
                };
                
                // Update politics components for all members
                for member in &event.members {
                    if let Ok(mut politics) = politics_components.get_mut(*member) {
                        for other_member in &event.members {
                            if *other_member != *member {
                                politics.alliances.insert(*other_member, alliance.strength.clone());
                            }
                        }
                    }
                }
            },
            AllianceEventType::Break => {
                // Handle alliance breaking
                for member in &event.members {
                    if let Ok(mut politics) = politics_components.get_mut(*member) {
                        for other_member in &event.members {
                            if *other_member != *member {
                                politics.alliances.remove(other_member);
                                
                                // Record broken alliance in history
                                politics.past_alliances.push(PastAlliance {
                                    with: *other_member,
                                    strength: event.strength.clone(),
                                    purpose: event.purpose.clone(),
                                    formed_at: event.formed_at.unwrap_or(0.0),
                                    broken_at: current_time,
                                    broken_reason: event.reason.clone(),
                                });
                            }
                        }
                    }
                }
            },
        }
    }
    
    // Check for expired alliances
    for mut politics in politics_components.iter_mut() {
        // Implementation for alliance expiration...
    }
}
}

AI Integration

For games with AI opponents, the politics system:

  • Models AI political decision making based on configured personalities
  • Evaluates deal proposals based on game state and risk assessment
  • Tracks human player tendencies for future political decisions
  • Simulates realistic political behavior for different AI difficulty levels
#![allow(unused)]
fn main() {
#[derive(Resource)]
pub struct AIPoliticsConfig {
    /// AI personality profiles
    pub personalities: HashMap<Entity, AIPoliticalPersonality>,
    /// AI decision making parameters
    pub decision_weights: AIPoliticsWeights,
    /// Historic interaction with players
    pub player_interaction_history: HashMap<Entity, PlayerInteractionHistory>,
}

#[derive(Clone, Debug)]
pub struct AIPoliticalPersonality {
    /// How aggressive the AI is
    pub aggression: f32,
    /// How trustworthy the AI is
    pub trustworthiness: f32,
    /// How risk-averse the AI is
    pub risk_aversion: f32,
    /// How vengeful the AI is
    pub vengefulness: f32,
    /// How the AI evaluates deals
    pub deal_evaluation_strategy: DealEvaluationStrategy,
}

/// System for AI deal evaluation
pub fn ai_evaluate_deal(
    ai_politics_config: Res<AIPoliticsConfig>,
    threat_assessment: Res<ThreatAssessment>,
    board_state: Res<BoardState>,
    deal: &Deal,
    ai_player: Entity,
) -> DealEvaluationResult {
    if let Some(personality) = ai_politics_config.personalities.get(&ai_player) {
        // Get AI personality
        let trust_factor = personality.trustworthiness;
        let risk_factor = personality.risk_aversion;
        
        // Calculate deal value
        let mut deal_value = 0.0;
        
        for term in &deal.terms {
            match term {
                DealTerm::NonAggression { target, duration } => {
                    // Value based on target threat and duration
                    let target_threat = threat_assessment.player_threats
                        .get(target)
                        .map(|t| t.0)
                        .unwrap_or(0.0);
                    
                    deal_value += target_threat * (*duration as f32) * 0.5;
                },
                // Evaluate other term types...
                _ => {},
            }
        }
        
        // Adjust for proposer's trustworthiness
        let proposer_trust = ai_politics_config.player_interaction_history
            .get(&ai_player)
            .and_then(|h| h.player_trust.get(&deal.proposer))
            .copied()
            .unwrap_or(0.5);
        
        deal_value *= proposer_trust;
        
        // Make decision based on value
        if deal_value > personality.deal_threshold {
            DealEvaluationResult::Accept
        } else {
            DealEvaluationResult::Reject { reason: "Not valuable enough".to_string() }
        }
    } else {
        // Default rejection if no personality
        DealEvaluationResult::Reject { reason: "No AI personality configured".to_string() }
    }
}
}

UI Components

The multiplayer politics UI provides:

  • Deal proposal interface with customizable terms
  • Alliance status indicators
  • Threat assessment visualization
  • Communication tools with appropriate filters
  • Deal history and player reputation tracking
#![allow(unused)]
fn main() {
#[derive(Component)]
pub struct PoliticsUIState {
    /// Currently selected player for political actions
    pub selected_player: Option<Entity>,
    /// Deal being constructed
    pub draft_deal: Option<DraftDeal>,
    /// UI mode
    pub ui_mode: PoliticsUIMode,
}

/// System for rendering politics UI
pub fn render_politics_ui(
    mut commands: Commands,
    mut egui_context: ResMut<EguiContext>,
    politics_ui_state: Res<PoliticsUIState>,
    politics_components: Query<&PoliticsComponent>,
    players: Query<(Entity, &PlayerName)>,
    threat_assessment: Res<ThreatAssessment>,
) {
    // Implementation of politics UI rendering...
}
}

Integration with Other Systems

The politics system integrates with several other game systems:

Combat System Integration

#![allow(unused)]
fn main() {
/// System for applying political factors to combat
pub fn apply_politics_to_combat(
    politics_components: Query<&PoliticsComponent>,
    mut attack_events: EventReader<DeclareAttackerEvent>,
    mut attack_modifiers: EventWriter<AttackModifierEvent>,
    alliances: Query<&Alliance>,
) {
    for event in attack_events.read() {
        if let Ok(attacker_politics) = politics_components.get(event.controller) {
            // Check for deals preventing attacks
            for deal in &attacker_politics.active_deals {
                for term in &deal.terms {
                    if let DealTerm::NonAggression { target, duration } = term {
                        if *target == event.defender && deal.status == DealStatus::Active {
                            // This attack would violate a non-aggression deal
                            attack_modifiers.send(AttackModifierEvent {
                                attacker: event.attacker,
                                defender: event.defender,
                                modification: AttackModification::PreventAttack {
                                    reason: "Non-aggression deal in effect".to_string(),
                                },
                            });
                        }
                    }
                }
            }
            
            // Check for alliances
            if let Some(alliance_strength) = attacker_politics.alliances.get(&event.defender) {
                // Alliance strength affects whether attack is allowed
                if *alliance_strength == AllianceStrength::Strong {
                    attack_modifiers.send(AttackModifierEvent {
                        attacker: event.attacker,
                        defender: event.defender,
                        modification: AttackModification::DissuadeAttack {
                            reason: "Strong alliance in effect".to_string(),
                            penalty: 2.0,
                        },
                    });
                }
            }
        }
    }
}
}

Card Effect Integration

#![allow(unused)]
fn main() {
/// System for applying political factors to targeted effects
pub fn apply_politics_to_targeting(
    politics_components: Query<&PoliticsComponent>,
    mut targeting_events: EventReader<TargetSelectionEvent>,
    mut targeting_modifiers: EventWriter<TargetingModifierEvent>,
) {
    for event in targeting_events.read() {
        if let Ok(caster_politics) = politics_components.get(event.controller) {
            // Check for deals affecting targeting
            for deal in &caster_politics.active_deals {
                for term in &deal.terms {
                    // Handle various deal terms affecting targeting
                    match term {
                        DealTerm::NoCounterspell { target, .. } => {
                            if *target == event.target_controller && 
                               is_counterspell(event.source) {
                                // This targeting would violate a no-counterspell deal
                                targeting_modifiers.send(TargetingModifierEvent {
                                    source: event.source,
                                    target: event.target,
                                    modification: TargetingModification::PreventTargeting {
                                        reason: "No-counterspell deal in effect".to_string(),
                                    },
                                });
                            }
                        },
                        // Handle other deal terms...
                        _ => {},
                    }
                }
            }
            
            // Check for alliances affecting targeting
            if let Some(alliance_strength) = caster_politics.alliances.get(&event.target_controller) {
                if is_negative_effect(event.source, event.target) {
                    match alliance_strength {
                        AllianceStrength::Strong => {
                            targeting_modifiers.send(TargetingModifierEvent {
                                source: event.source,
                                target: event.target,
                                modification: TargetingModification::DissuadeTargeting {
                                    reason: "Strong alliance in effect".to_string(),
                                    penalty: 3.0,
                                },
                            });
                        },
                        // Handle other alliance levels...
                        _ => {},
                    }
                }
            }
        }
    }
}
}

Constraints and Limitations

The politics system operates within these constraints:

  • No rules enforcement of political agreements (maintaining game integrity)
  • Appropriate limits on information sharing for hidden information
  • Configurable table talk policies to match playgroup preferences
  • Balance between automation and player agency in political decisions

Testing Politics Features

#![allow(unused)]
fn main() {
#[test]
fn test_deal_creation_and_acceptance() {
    let mut app = App::new();
    setup_test_game(&mut app);
    
    // Create players
    let player1 = app.world.spawn((
        Player,
        PlayerName("Player 1".to_string()),
        PoliticsComponent::default(),
    )).id();
    
    let player2 = app.world.spawn((
        Player,
        PlayerName("Player 2".to_string()),
        PoliticsComponent::default(),
    )).id();
    
    // Create a deal proposal
    let deal_terms = vec![
        DealTerm::NonAggression {
            target: player2,
            duration: 2,
        },
    ];
    
    app.world.send_event(DealProposalEvent {
        proposer: player1,
        targets: vec![player2],
        terms: deal_terms,
        expiration: Some(DealExpiration::Turns(2)),
        timestamp: 0.0,
    });
    
    app.update();
    
    // Check that deal was created
    let politics1 = app.world.get::<PoliticsComponent>(player1).unwrap();
    assert_eq!(politics1.active_deals.len(), 1);
    assert_eq!(politics1.active_deals[0].status, DealStatus::Proposed);
    
    // Accept the deal
    let deal_id = politics1.active_deals[0].id;
    app.world.send_event(DealResponseEvent {
        deal_id,
        response_type: DealResponseType::Accept,
        player: player2,
        timestamp: 1.0,
    });
    
    app.update();
    
    // Verify deal is now active
    let politics1_updated = app.world.get::<PoliticsComponent>(player1).unwrap();
    assert_eq!(politics1_updated.active_deals[0].status, DealStatus::Active);
    assert_eq!(politics1_updated.active_deals[0].acceptors, vec![player2]);
}

#[test]
fn test_goad_mechanics() {
    let mut app = App::new();
    setup_test_game(&mut app);
    
    // Create players
    let player1 = app.world.spawn(Player).id();
    let player2 = app.world.spawn(Player).id();
    
    // Create a creature
    let creature = app.world.spawn((
        CardName("Test Creature".to_string()),
        CardType::Creature,
        Power(3),
        Toughness(3),
        Controller(player1),
        OnBattlefield,
    )).id();
    
    // Goad the creature
    app.world.entity_mut(creature).insert(Goaded {
        goaded_by: player2,
        expires_at: ExpiryTiming::EndOfTurn,
    });
    
    // Set up combat phase
    app.world.resource_mut::<TurnManager>().current_phase = Phase::Combat(CombatStep::DeclareAttackers);
    app.world.resource_mut::<TurnManager>().active_player = player1;
    
    app.update();
    
    // Get attack requirements
    let attack_requirements = app.world.resource::<Events<AttackRequirementEvent>>()
        .get_reader()
        .read(&app.world.resource::<Events<AttackRequirementEvent>>())
        .collect::<Vec<_>>();
    
    // Verify goad requirements are enforced
    assert!(!attack_requirements.is_empty());
    assert_eq!(attack_requirements[0].creature, creature);
    assert!(attack_requirements[0].must_attack);
    assert_eq!(attack_requirements[0].cannot_attack, vec![player2]);
}
}

Politics System Testing

Overview

This document outlines the testing approach for the Multiplayer Politics system within the Commander game engine. Due to the complex nature of political interactions and their impact on game state, thorough testing is essential to ensure correct behavior and integration with other components.

Test Categories

The Politics system testing is organized into several key categories:

1. Unit Tests

Unit tests focus on isolated functionality of the Politics system components:

#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
    use super::*;
    
    #[test]
    fn test_monarch_transitions() {
        // Test monarch assignment and transitions
    }
    
    #[test]
    fn test_vote_tallying() {
        // Test vote counting and resolution
    }
    
    #[test]
    fn test_deal_validation() {
        // Test deal validation logic
    }
    
    #[test]
    fn test_alliance_formation() {
        // Test alliance creation and tracking
    }
    
    #[test]
    fn test_goad_effects() {
        // Test goad mechanics and expiration
    }
}
}

2. Integration Tests

Integration tests verify Politics system interactions with other game systems:

  • Combat integration (goad effects, alliance-based combat restrictions)
  • Turn structure integration (monarch draw effects, deal expiration)
  • Card effects that interact with political mechanics
  • UI feedback for political events
  • Threat assessment impact on AI decision making

3. End-to-End Tests

End-to-end tests simulate full game scenarios involving political elements:

  • Four-player game with complex political interactions
  • AI decision-making in political contexts
  • Network synchronization of political state
  • Full game simulations with political mechanics enabled

Testing The Monarch Mechanic

The Monarch mechanic requires specific test cases:

  1. Monarch Assignment

    • Test initial monarch assignment
    • Test monarch transfer through card effects
    • Test monarch transfer through combat damage
  2. Monarch Effects

    • Verify correct card draw during end step
    • Test interaction with replacement effects
    • Validate monarch status persistence across turns
  3. Edge Cases

    • Test monarch behavior when player loses/leaves game
    • Test simultaneous monarch-granting effects
    • Test prevention of monarch transfer
#![allow(unused)]
fn main() {
#[test]
fn test_monarch_combat_transfer() {
    let mut app = App::new();
    setup_test_game(&mut app);
    
    // Create players
    let player1 = app.world.spawn(Player).id();
    let player2 = app.world.spawn(Player).id();
    
    // Set initial monarch
    let mut monarch_state = MonarchState {
        current_monarch: Some(player1),
        last_changed: Some(0.0),
    };
    app.insert_resource(monarch_state);
    
    // Player entity should have Monarch component
    app.world.entity_mut(player1).insert(Monarch);
    
    // Create attacking creature
    let creature = app.world.spawn((
        CardName("Test Creature".to_string()),
        CardType::Creature,
        Power(3),
        Toughness(3),
        Controller(player2),
        OnBattlefield,
    )).id();
    
    // Send combat damage event
    app.world.send_event(CombatDamageEvent {
        source: creature,
        target: player1,
        amount: 3,
        is_commander_damage: false,
        attacking_player_controller: player2,
        defending_player: player1,
    });
    
    app.update();
    
    // Verify monarch transferred
    let updated_monarch = app.world.resource::<MonarchState>();
    assert_eq!(updated_monarch.current_monarch, Some(player2));
    assert!(app.world.entity(player2).contains::<Monarch>());
    assert!(!app.world.entity(player1).contains::<Monarch>());
}
}

Testing The Voting System

The voting system tests include:

  1. Vote Initialization

    • Test vote creation and option definition
    • Validate vote accessibility to all players
    • Test timing restrictions on votes
  2. Vote Casting

    • Test basic vote casting mechanics
    • Test vote weight modifiers (e.g., extra votes from effects)
    • Validate vote time limits
    • Test voting for multiple options (Council's Dilemma)
  3. Vote Resolution

    • Test tie-breaking logic
    • Verify correct application of voting results
    • Test effects that modify vote outcomes
    • Test scaling effects based on vote counts
#![allow(unused)]
fn main() {
#[test]
fn test_voting_resolution() {
    let mut app = App::new();
    setup_test_game(&mut app);
    
    // Create players
    let player1 = app.world.spawn(Player).id();
    let player2 = app.world.spawn(Player).id();
    let player3 = app.world.spawn(Player).id();
    
    // Create voting card
    let voting_card = app.world.spawn((
        CardName("Council's Judgment".to_string()),
        VotingMechanic {
            voting_type: VotingType::WillOfCouncil,
            options: vec!["Option A".to_string(), "Option B".to_string()],
            resolution: VoteResolutionMethod::MostVotes,
        },
        Controller(player1),
    )).id();
    
    // Cast votes
    let votes = HashMap::from([
        (player1, "Option A".to_string()),
        (player2, "Option B".to_string()),
        (player3, "Option A".to_string()),
    ]);
    
    // Send vote completion event
    app.world.send_event(VoteCompletionEvent {
        source_card: voting_card,
        votes,
        timestamp: 1.0,
    });
    
    app.update();
    
    // Verify correct effect was applied for winning option "Option A"
    // Further assertions based on the expected outcome
}
}

Testing The Deal System

The deal system requires comprehensive testing:

  1. Deal Creation

    • Test deal proposal structure
    • Validate term specification
    • Test duration settings
    • Test deal limits per player
  2. Deal Negotiation

    • Test acceptance/rejection mechanics
    • Test counter-proposal handling
    • Validate notification system
    • Test multi-player deals
  3. Deal Enforcement

    • Test automatic deal monitoring
    • Validate deal violation detection
    • Test consequences application
    • Test enforcement of complex terms
  4. Deal History

    • Test deal history tracking
    • Validate reputation system updates
    • Test history influence on AI decisions
    • Test broken deal statistics
#![allow(unused)]
fn main() {
#[test]
fn test_deal_lifecycle() {
    let mut app = App::new();
    setup_test_game(&mut app);
    
    // Create players with politics components
    let player1 = app.world.spawn((
        Player,
        PlayerName("Player 1".to_string()),
        PoliticsComponent::default(),
    )).id();
    
    let player2 = app.world.spawn((
        Player,
        PlayerName("Player 2".to_string()),
        PoliticsComponent::default(),
    )).id();
    
    // Create a deal proposal
    let deal_terms = vec![
        DealTerm::NonAggression {
            target: player2,
            duration: 2,
        },
    ];
    
    app.world.send_event(DealProposalEvent {
        proposer: player1,
        targets: vec![player2],
        terms: deal_terms,
        expiration: Some(DealExpiration::Turns(2)),
        timestamp: 0.0,
    });
    
    app.update();
    
    // Verify deal proposal created
    let politics1 = app.world.get::<PoliticsComponent>(player1).unwrap();
    assert_eq!(politics1.active_deals.len(), 1);
    assert_eq!(politics1.active_deals[0].status, DealStatus::Proposed);
    
    // Accept the deal
    let deal_id = politics1.active_deals[0].id;
    app.world.send_event(DealResponseEvent {
        deal_id,
        response_type: DealResponseType::Accept,
        player: player2,
        timestamp: 1.0,
    });
    
    app.update();
    
    // Verify deal is now active
    let politics1_updated = app.world.get::<PoliticsComponent>(player1).unwrap();
    assert_eq!(politics1_updated.active_deals[0].status, DealStatus::Active);
    
    // Test deal expiration
    advance_game_turns(&mut app, 2);
    app.update();
    
    // Verify deal has expired
    let politics1_final = app.world.get::<PoliticsComponent>(player1).unwrap();
    assert_eq!(politics1_final.active_deals[0].status, DealStatus::Expired);
}
}

Testing Alliance Mechanics

The alliance system requires specific test cases:

  1. Alliance Formation

    • Test alliance creation
    • Test alliance strength levels
    • Validate multi-player alliance formation
  2. Alliance Effects

    • Test combat restrictions based on alliances
    • Test targeting restrictions for allied players
    • Validate benefits between allied players
  3. Alliance Dissolution

    • Test voluntary alliance breaking
    • Test automatic alliance expiration
    • Validate alliance history tracking
#![allow(unused)]
fn main() {
#[test]
fn test_alliance_combat_restrictions() {
    let mut app = App::new();
    setup_test_game(&mut app);
    
    // Create players
    let player1 = app.world.spawn((
        Player,
        PoliticsComponent::default(),
    )).id();
    
    let player2 = app.world.spawn((
        Player,
        PoliticsComponent::default(),
    )).id();
    
    // Create strong alliance
    let mut politics1 = app.world.get_mut::<PoliticsComponent>(player1).unwrap();
    politics1.alliances.insert(player2, AllianceStrength::Strong);
    
    // Create creature
    let creature = app.world.spawn((
        CardType::Creature,
        Controller(player1),
        OnBattlefield,
    )).id();
    
    // Attempt to attack allied player
    app.world.send_event(DeclareAttackerEvent {
        attacker: creature,
        defender: player2,
        controller: player1,
    });
    
    app.update();
    
    // Check for attack modification event
    let attack_mods = app.world.resource::<Events<AttackModifierEvent>>()
        .get_reader()
        .read(&app.world.resource::<Events<AttackModifierEvent>>())
        .collect::<Vec<_>>();
    
    assert!(!attack_mods.is_empty());
    assert_eq!(attack_mods[0].attacker, creature);
    assert_eq!(attack_mods[0].defender, player2);
    
    // Verify correct modification type (should be DissuadeAttack for strong alliance)
    match &attack_mods[0].modification {
        AttackModification::DissuadeAttack { reason, penalty } => {
            assert!(reason.contains("alliance"));
            assert!(*penalty > 0.0);
        },
        _ => panic!("Expected DissuadeAttack modification"),
    }
}
}

Testing Goad Mechanics

Goad mechanics require specific testing:

  1. Goad Application

    • Test applying goad to creatures
    • Test goad duration tracking
    • Test multiple simultaneous goad effects
  2. Goad Attack Requirements

    • Test forced attack requirement
    • Test restriction on attacking goader
    • Validate legal target determination
  3. Goad Edge Cases

    • Test goad with no legal attack targets
    • Test goad when creature cannot attack
    • Test interaction with other attack restrictions
#![allow(unused)]
fn main() {
#[test]
fn test_goad_mechanics() {
    let mut app = App::new();
    setup_test_game(&mut app);
    
    // Create 3 players
    let player1 = app.world.spawn(Player).id();
    let player2 = app.world.spawn(Player).id();
    let player3 = app.world.spawn(Player).id();
    
    // Create a creature
    let creature = app.world.spawn((
        CardName("Test Creature".to_string()),
        CardType::Creature,
        Power(3),
        Toughness(3),
        Controller(player1),
        OnBattlefield,
        CanAttack(true),
    )).id();
    
    // Goad the creature
    app.world.entity_mut(creature).insert(Goaded {
        goaded_by: player2,
        expires_at: ExpiryTiming::EndOfTurn,
    });
    
    // Set up combat phase
    app.world.resource_mut::<TurnManager>().current_phase = Phase::Combat(CombatStep::DeclareAttackers);
    app.world.resource_mut::<TurnManager>().active_player = player1;
    
    app.update();
    
    // Get attack requirements
    let attack_requirements = app.world.resource::<Events<AttackRequirementEvent>>()
        .get_reader()
        .read(&app.world.resource::<Events<AttackRequirementEvent>>())
        .collect::<Vec<_>>();
    
    // Verify goad requirements are enforced
    assert!(!attack_requirements.is_empty());
    assert_eq!(attack_requirements[0].creature, creature);
    assert!(attack_requirements[0].must_attack);
    assert_eq!(attack_requirements[0].cannot_attack, vec![player2]);
    
    // Advance turn to test goad expiration
    advance_turn(&mut app);
    
    // Verify goad has expired
    assert!(!app.world.entity(creature).contains::<Goaded>());
}
}

Testing Threat Assessment

The threat assessment system requires verification:

  1. Threat Calculation

    • Test basic threat score calculation
    • Test contribution of different threat factors
    • Validate dynamic threat updates
  2. Threat Visualization

    • Test threat UI display
    • Test threat history tracking
    • Validate threat factor breakdown
  3. AI Integration

    • Test AI use of threat assessment
    • Validate threat-based targeting
    • Test strategic adjustments based on threat
#![allow(unused)]
fn main() {
#[test]
fn test_threat_assessment() {
    let mut app = App::new();
    setup_test_game(&mut app);
    
    // Create players
    let player1 = app.world.spawn(Player).id();
    let player2 = app.world.spawn(Player).id();
    
    // Initialize threat assessment
    app.insert_resource(ThreatAssessment {
        player_threats: HashMap::new(),
        threat_factors: HashMap::new(),
        threat_history: HashMap::new(),
    });
    
    // Create board state for player1
    for _ in 0..5 {
        app.world.spawn((
            CardType::Creature,
            Controller(player1),
            OnBattlefield,
        ));
    }
    
    // Create commander for player1
    app.world.spawn((
        CardType::Creature,
        Commander,
        Controller(player1),
        OnBattlefield,
    ));
    
    // Create smaller board for player2
    for _ in 0..2 {
        app.world.spawn((
            CardType::Creature,
            Controller(player2),
            OnBattlefield,
        ));
    }
    
    // Run threat assessment system
    app.add_systems(Update, update_threat_assessment);
    app.update();
    
    // Check threat levels
    let threat = app.world.resource::<ThreatAssessment>();
    
    // Player1 should have higher threat than player2
    let player1_threat = threat.player_threats.get(&player1).unwrap();
    let player2_threat = threat.player_threats.get(&player2).unwrap();
    
    assert!(player1_threat.0 > player2_threat.0);
    
    // Verify threat factors were recorded
    assert!(!threat.threat_factors.get(&player1).unwrap().is_empty());
    
    // Check commander presence factor
    let commander_factor = threat.threat_factors.get(&player1).unwrap()
        .iter()
        .find(|f| matches!(f.factor_type, ThreatFactorType::CommanderPresence));
    
    assert!(commander_factor.is_some());
}
}

Mock Testing Approaches

For complex political scenarios, mock testing is employed:

#![allow(unused)]
fn main() {
#[test]
fn test_complex_political_scenario() {
    let mut app = App::new();
    
    // Setup test environment
    app.add_plugins(MinimalPlugins)
       .add_plugin(PoliticsPlugin)
       .add_systems(Startup, setup_test_scenario);
       
    // Mock player actions
    let players = setup_four_player_game(&mut app);
    
    // Simulate complex political scenario
    simulate_monarchy_contest(&mut app, &players);
    simulate_deal_making(&mut app, &players);
    simulate_voting_session(&mut app, &players);
    simulate_alliance_formation(&mut app, &players);
    simulate_threat_based_targeting(&mut app, &players);
    
    // Verify expected outcomes
    verify_political_state(&app, &expected_state);
}
}

Property-Based Testing

For robust validation of political mechanics, property-based testing is used:

#![allow(unused)]
fn main() {
#[test]
fn test_voting_properties() {
    // Property: All votes must be counted
    proptest!(|(votes: Vec<(Entity, VoteChoice)>)| {
        let result = tally_votes(&votes);
        assert_eq!(result.total_votes, votes.len());
    });
    
    // Property: Winning option must have most votes
    proptest!(|(votes: Vec<(Entity, VoteChoice)>)| {
        let result = tally_votes(&votes);
        // Verify winning option has highest count
    });
    
    // Property: Goad must always allow at least one legal attack target
    proptest!(|(players: Vec<Entity>, goaded_by: Entity)| {
        let legal_targets = determine_legal_attack_targets(players.clone(), goaded_by);
        // Verify at least one legal target if there are enough players
        if players.len() > 2 {
            assert!(!legal_targets.is_empty());
        }
    });
}
}

Network Testing

Political systems must be tested for correct network behavior:

  1. State Synchronization

    • Test monarch status synchronization
    • Validate vote transmission and collection
    • Test deal state synchronization
    • Test alliance state replication
  2. Latency Handling

    • Test delayed political decisions
    • Validate timeout handling for votes/deals
    • Test recovery from connection issues
  3. Conflict Resolution

    • Test resolution of conflicting political actions
    • Validate deterministic outcomes across peers

Regression Test Suite

The Politics system maintains a regression test suite covering:

  1. Known past issues and their fixes
  2. Edge cases discovered during development
  3. Community-reported political interaction bugs

Performance Testing

Political mechanics are tested for performance impacts:

  1. Scaling Tests

    • Performance with many simultaneous deals
    • Vote tallying with large player counts
    • Political state update propagation
    • Threat assessment with complex board states
  2. Memory Usage

    • Test memory footprint of complex political states
    • Validate cleanup of expired political objects

Test Fixtures

Reusable test fixtures include:

  • Standard 4-player table setup
  • Pre-defined political scenarios
  • Mock AI political personalities
  • Reference deal/vote templates
  • Standard threat assessment profiles

Continuous Integration

Politics tests are integrated into CI with:

  • Automated test runs on PR submission
  • Coverage reports for political mechanics
  • Performance comparison against baseline
  • Parallel testing of different political scenarios

Partner Commanders

This document details the implementation of partner commanders and related mechanics in the Commander format.

Overview

Partner is a mechanic introduced in Commander 2016 that allows a deck to have two commanders instead of one. There are several variations of the partner mechanic:

  1. Traditional Partners: Cards with the text "Partner" can pair with any other card that has Partner
  2. Partner With: Cards with "Partner with [specific card]" can only pair with that specific card
  3. Background: A commander that can have a Background enchantment as a second commander
  4. Friends Forever: Cards with "Friends Forever" can pair with any other card that has Friends Forever

Rules Implementation

Core Partner Rules

The partner mechanic modifies several fundamental Commander rules:

  1. Two commanders instead of one
  2. Color identity is the combined colors of both commanders
  3. Starting life total and commander damage tracking apply to each commander separately
  4. Both commanders start in the command zone
  5. Commander tax applies to each commander separately

Implementation Details

#![allow(unused)]
fn main() {
/// Component marking an entity as a partner commander
#[derive(Component, Clone, Debug)]
pub enum PartnerType {
    /// Can partner with any other commander with Partner
    Universal,
    /// Can only partner with a specific commander
    Specific(Entity),
    /// Can have a Background as a partner
    CanHaveBackground,
    /// Is a Background enchantment
    IsBackground,
    /// Can partner with any Friends Forever commander
    FriendsForever,
}

/// Resource tracking partner commanders
#[derive(Resource)]
pub struct PartnerSystem {
    /// Maps players to their partner commanders
    pub player_partners: HashMap<Entity, Vec<Entity>>,
    /// Tracks which commanders are partnered together
    pub partnered_with: HashMap<Entity, Entity>,
}

/// System to validate partner legality during deck construction
pub fn validate_partner_legality(
    player: Entity,
    deck: &Deck,
    partners: &Query<(Entity, &CardName, &PartnerType)>,
) -> Result<(), DeckValidationError> {
    // Get commanders marked as partners
    let commander_entities = deck.get_commanders();
    
    // Filter to keep only entities with Partner component
    let partner_entities: Vec<Entity> = partners
        .iter_many(commander_entities)
        .map(|(entity, _, _)| entity)
        .collect();
    
    // Validate partner relationships
    match partner_entities.len() {
        0 => Ok(()), // No partners, standard single commander
        1 => Err(DeckValidationError::SinglePartnerNotAllowed), // Single partner needs a pair
        2 => validate_two_partners(&partner_entities, partners), // Check if these two can partner
        _ => Err(DeckValidationError::TooManyPartners), // More than 2 partners not allowed
    }
}

/// Validates if two commanders can be partners
fn validate_two_partners(
    entities: &[Entity],
    partners: &Query<(Entity, &CardName, &PartnerType)>,
) -> Result<(), DeckValidationError> {
    let (entity_a, name_a, type_a) = partners.get(entities[0]).unwrap();
    let (entity_b, name_b, type_b) = partners.get(entities[1]).unwrap();
    
    match (type_a, type_b) {
        // Universal partners can pair with any other universal partner
        (PartnerType::Universal, PartnerType::Universal) => Ok(()),
        
        // Specific partners can only pair with their named partner
        (PartnerType::Specific(target), _) if *target == entity_b => Ok(()),
        (_, PartnerType::Specific(target)) if *target == entity_a => Ok(()),
        
        // Background pairings
        (PartnerType::CanHaveBackground, PartnerType::IsBackground) => Ok(()),
        (PartnerType::IsBackground, PartnerType::CanHaveBackground) => Ok(()),
        
        // Friends Forever pairings
        (PartnerType::FriendsForever, PartnerType::FriendsForever) => Ok(()),
        
        // All other combinations are invalid
        _ => Err(DeckValidationError::InvalidPartnerCombination {
            commander_a: name_a.0.clone(),
            commander_b: name_b.0.clone(),
        }),
    }
}
}

Color Identity with Partners

A deck with partner commanders uses the combined color identity of both commanders:

#![allow(unused)]
fn main() {
/// Calculate color identity for a deck with partners
pub fn calculate_partner_color_identity(
    commanders: &[Entity],
    identity_query: &Query<&ColorIdentity>,
) -> ColorIdentity {
    let mut combined_identity = ColorIdentity::default();
    
    for commander in commanders {
        if let Ok(identity) = identity_query.get(*commander) {
            combined_identity = combined_identity.union(identity);
        }
    }
    
    combined_identity
}
}

Partner Variations

Partner With

The "Partner with" mechanic has additional functionality beyond allowing two commanders:

  1. When either commander enters the battlefield, its controller may search their library for the other
  2. This tutor effect requires special implementation
#![allow(unused)]
fn main() {
/// Component for the "Partner with" tutoring ability
#[derive(Component)]
pub struct PartnerWithTutorAbility {
    pub partner_name: String,
}

/// System that handles the "Partner with" tutoring ability
pub fn handle_partner_with_tutor(
    mut commands: Commands,
    mut entered_battlefield: EventReader<EnteredBattlefieldEvent>,
    tutor_abilities: Query<(&PartnerWithTutorAbility, &Owner)>,
    mut tutor_events: EventWriter<TutorEvent>,
) {
    for event in entered_battlefield.read() {
        if let Ok((ability, owner)) in tutor_abilities.get(event.entity) {
            // Create a tutor effect allowing player to search for the partner
            tutor_events.send(TutorEvent {
                player: owner.0,
                card_name: ability.partner_name.clone(),
                origin: event.entity,
                destination: Zone::Hand,
                optional: true,
            });
        }
    }
}
}

Background

The Background mechanic, introduced in Commander Legends: Battle for Baldur's Gate, allows certain commanders to have a Background enchantment as their second commander:

#![allow(unused)]
fn main() {
/// Component marking a card as a Background
#[derive(Component)]
pub struct Background;

/// Component for commanders that can have a Background
#[derive(Component)]
pub struct CanHaveBackground;

/// System to validate Background legality
pub fn validate_background_legality(
    commanders: &[Entity],
    can_have_query: &Query<Entity, With<CanHaveBackground>>,
    background_query: &Query<Entity, With<Background>>,
) -> Result<(), DeckValidationError> {
    if commanders.len() != 2 {
        return Ok(());
    }
    
    let has_commander_with_background = can_have_query
        .iter_many(commanders)
        .count() == 1;
        
    let has_background = background_query
        .iter_many(commanders)
        .count() == 1;
    
    if has_commander_with_background && has_background {
        Ok(())
    } else if has_commander_with_background || has_background {
        Err(DeckValidationError::IncompleteBackgroundPairing)
    } else {
        Ok(()) // Not using Background mechanic
    }
}
}

User Interface Considerations

The UI needs special handling for partner commanders:

  1. Both commanders need to be displayed in the command zone
  2. Players need a way to choose which commander to cast
  3. Commander tax display needs to track each commander separately

Testing Partner Mechanics

#![allow(unused)]
fn main() {
#[test]
fn test_universal_partners() {
    let mut app = App::new();
    app.add_systems(Startup, setup_test_partners);
    app.add_systems(Update, validate_partner_legality);
    
    // Test universal partners (e.g., Thrasios and Tymna)
    let thrasios = app.world.spawn((
        CardName("Thrasios, Triton Hero".to_string()),
        PartnerType::Universal,
    )).id();
    
    let tymna = app.world.spawn((
        CardName("Tymna the Weaver".to_string()),
        PartnerType::Universal,
    )).id();
    
    let deck = create_test_deck(vec![thrasios, tymna]);
    let result = validate_deck(&app.world, deck);
    assert!(result.is_ok());
}

#[test]
fn test_partners_with() {
    // Test for "Partner with" mechanic (e.g., Brallin and Shabraz)
    // Implementation details...
}

#[test]
fn test_background() {
    // Test for Background mechanic
    // Implementation details...
}
}

Commander Ninjutsu

This document details the implementation of the Commander Ninjutsu mechanic in the Commander format.

Overview

Commander Ninjutsu is a Commander-specific variant of the Ninjutsu mechanic, introduced on the card "Yuriko, the Tiger's Shadow" from Commander 2018. It allows a commander with this ability to be put onto the battlefield from the command zone, bypassing commander tax.

Mechanic Definition

Commander Ninjutsu {Cost} — {Cost}, Return an unblocked attacker you control to hand: Put this card from the command zone onto the battlefield tapped and attacking.

Key differences from regular Ninjutsu:

  1. It can be activated from the command zone (instead of only from hand)
  2. It bypasses the need to cast the commander, avoiding commander tax
  3. It allows the commander to enter the battlefield directly attacking

Rules Implementation

Core Commander Ninjutsu Rules

#![allow(unused)]
fn main() {
/// Component for cards with Commander Ninjutsu
#[derive(Component, Clone, Debug)]
pub struct CommanderNinjutsu {
    /// Mana cost to activate the ability
    pub cost: ManaCost,
}

/// Event triggered when a Commander Ninjutsu ability is activated
#[derive(Event)]
pub struct CommanderNinjutsuEvent {
    /// The commander with ninjutsu being put onto the battlefield
    pub commander: Entity,
    /// The creature being returned to hand
    pub returned_creature: Entity,
    /// The player being attacked
    pub defending_player: Entity,
    /// The player activating the ability
    pub controller: Entity,
}

/// System that handles Commander Ninjutsu activation
pub fn handle_commander_ninjutsu(
    mut commands: Commands,
    mut ninjutsu_events: EventReader<CommanderNinjutsuEvent>,
    mut zone_transitions: EventWriter<ZoneTransitionEvent>,
    mut attacking_status: EventWriter<SetAttackingEvent>,
    command_zone: Query<&CommandZone>,
) {
    for event in ninjutsu_events.read() {
        // Verify commander is in command zone
        if !is_in_command_zone(event.commander, &command_zone) {
            continue;
        }
        
        // 1. Return the unblocked attacker to hand
        zone_transitions.send(ZoneTransitionEvent {
            entity: event.returned_creature,
            from: Zone::Battlefield,
            to: Zone::Hand,
            cause: TransitionCause::Ability {
                source: event.commander,
                ability_name: "Commander Ninjutsu".to_string(),
            },
        });
        
        // 2. Put commander onto battlefield
        zone_transitions.send(ZoneTransitionEvent {
            entity: event.commander,
            from: Zone::Command,
            to: Zone::Battlefield,
            cause: TransitionCause::Ability {
                source: event.commander,
                ability_name: "Commander Ninjutsu".to_string(),
            },
        });
        
        // 3. Set commander as tapped and attacking
        commands.entity(event.commander).insert(Tapped);
        
        attacking_status.send(SetAttackingEvent {
            attacker: event.commander,
            defending_player: event.defending_player,
        });
    }
}
}

Activation Requirements

Commander Ninjutsu can only be activated during specific game states:

#![allow(unused)]
fn main() {
/// System to determine when Commander Ninjutsu can be activated
pub fn can_activate_commander_ninjutsu(
    commander: Entity,
    controller: Entity,
    game_state: &GameState,
    command_zone: &Query<&CommandZone>,
    ninjutsu_abilities: &Query<&CommanderNinjutsu>,
    attacking_creatures: &Query<(Entity, &AttackingStatus, &Controller)>,
) -> bool {
    // 1. Must be in the combat phase, after blockers are declared
    if !matches!(game_state.current_phase, 
                Phase::Combat(CombatStep::DeclareBlockers | 
                               CombatStep::CombatDamage)) {
        return false;
    }
    
    // 2. Commander must be in command zone
    if !is_in_command_zone(commander, command_zone) {
        return false;
    }
    
    // 3. Must have Commander Ninjutsu ability
    if !ninjutsu_abilities.contains(commander) {
        return false;
    }
    
    // 4. Must have an unblocked attacker to return to hand
    let has_unblocked_attacker = attacking_creatures
        .iter()
        .any(|(entity, status, creature_controller)| {
            creature_controller.0 == controller && 
            status.is_unblocked()
        });
    
    has_unblocked_attacker
}
}

Mana Cost and Commander Tax

The Commander Ninjutsu ability itself has a fixed cost that does not increase when the commander is put into the command zone:

#![allow(unused)]
fn main() {
/// Function to get the mana cost for activating Commander Ninjutsu
pub fn get_commander_ninjutsu_cost(
    commander: Entity,
    ninjutsu_abilities: &Query<&CommanderNinjutsu>,
) -> ManaCost {
    // Commander Ninjutsu cost is fixed and doesn't include commander tax
    if let Ok(ninjutsu) = ninjutsu_abilities.get(commander) {
        ninjutsu.cost.clone()
    } else {
        ManaCost::default() // Fallback, shouldn't happen
    }
}
}

However, the normal casting cost of the commander still increases each time the commander is put into the command zone from anywhere:

#![allow(unused)]
fn main() {
/// System that tracks commander movement to command zone to apply tax
pub fn track_commander_tax(
    mut tax_events: EventWriter<IncrementCommanderTaxEvent>,
    mut zone_transition_events: EventReader<ZoneTransitionEvent>,
    commander_query: Query<&Commander>,
) {
    for event in zone_transition_events.read() {
        if event.to == Zone::Command && commander_query.contains(event.entity) {
            // Increment commander tax, even for commanders with Ninjutsu
            tax_events.send(IncrementCommanderTaxEvent {
                commander: event.entity,
            });
        }
    }
}
}

User Interface Considerations

The UI needs special handling for Commander Ninjutsu:

  1. Ability must be presented as an option during combat after blockers are declared
  2. UI must show which unblocked attackers can be returned to hand
  3. Cost display should show the fixed Ninjutsu cost (not affected by commander tax)
#![allow(unused)]
fn main() {
/// Function to get available Commander Ninjutsu actions
pub fn get_commander_ninjutsu_actions(
    player: Entity,
    game_state: &GameState,
    commanders: &Query<(Entity, &Commander, &CommanderNinjutsu, &Owner)>,
    unblocked_attackers: &Query<(Entity, &AttackingStatus, &Controller)>,
) -> Vec<CommanderNinjutsuAction> {
    let mut actions = Vec::new();
    
    // Only check during appropriate combat steps
    if !matches!(game_state.current_phase, 
                Phase::Combat(CombatStep::DeclareBlockers | 
                              CombatStep::CombatDamage)) {
        return actions;
    }
    
    // Get all unblocked attackers controlled by player
    let available_attackers: Vec<Entity> = unblocked_attackers
        .iter()
        .filter(|(_, status, controller)| {
            controller.0 == player && status.is_unblocked()
        })
        .map(|(entity, _, _)| entity)
        .collect();
    
    if available_attackers.is_empty() {
        return actions;
    }
    
    // Find commanders with ninjutsu in command zone
    for (commander, _, ninjutsu, owner) in commanders.iter() {
        if owner.0 == player && is_in_command_zone(commander, &game_state.zones) {
            // For each available attacker, create a potential action
            for attacker in &available_attackers {
                actions.push(CommanderNinjutsuAction {
                    commander,
                    unblocked_attacker: *attacker,
                    defending_player: get_defending_player(*attacker, &game_state),
                    cost: ninjutsu.cost.clone(),
                });
            }
        }
    }
    
    actions
}
}

Notable Cards with Commander Ninjutsu

Currently, only one card has Commander Ninjutsu:

  • Yuriko, the Tiger's Shadow (Commander 2018)
    • Commander Ninjutsu {1}{U}{B}
    • Whenever Yuriko deals combat damage to a player, reveal the top card of your library and put it into your hand. You lose life equal to that card's mana value.
    • Partner With: None
    • Color Identity: Blue-Black

Implementation of Yuriko:

#![allow(unused)]
fn main() {
pub fn create_yuriko() -> impl Bundle {
    (
        CardName("Yuriko, the Tiger's Shadow".to_string()),
        CardType::Creature,
        CreatureType(vec!["Human".to_string(), "Ninja".to_string()]),
        Commander,
        Power(1),
        Toughness(3),
        ManaCost::parse("{1}{U}{B}"),
        ColorIdentity::parse("{U}{B}"),
        CommanderNinjutsu {
            cost: ManaCost::parse("{1}{U}{B}"),
        },
        CombatDamageTriggeredAbility {
            trigger_condition: CombatDamageCondition::DamageToPlayer,
            ability: Box::new(YurikoRevealEffect),
        },
    )
}
}

Testing Commander Ninjutsu

#![allow(unused)]
fn main() {
#[test]
fn test_commander_ninjutsu_activation() {
    let mut app = App::new();
    app.add_systems(Startup, setup_combat_test);
    app.add_systems(Update, (
        handle_commander_ninjutsu,
        track_commander_tax,
    ));
    
    // Create test entities
    let player = app.world.spawn_empty().id();
    let opponent = app.world.spawn_empty().id();
    
    // Create Yuriko commander in command zone
    let yuriko = app.world.spawn((
        CardName("Yuriko, the Tiger's Shadow".to_string()),
        Commander,
        CommanderNinjutsu { cost: ManaCost::parse("{1}{U}{B}") },
        Owner(player),
    )).id();
    
    // Add to command zone
    app.world.resource_mut::<Zones>().command.insert(yuriko);
    
    // Create an unblocked attacker
    let attacker = app.world.spawn((
        CardName("Ninja of the Deep Hours".to_string()),
        Controller(player),
        AttackingStatus {
            attacking: true,
            blocked: false,
            defending_player: Some(opponent),
        },
    )).id();
    
    // Set game state to post-blockers
    app.world.resource_mut::<GameState>().current_phase = Phase::Combat(CombatStep::DeclareBlockers);
    
    // Activate commander ninjutsu
    app.world.send_event(CommanderNinjutsuEvent {
        commander: yuriko,
        returned_creature: attacker,
        defending_player: opponent,
        controller: player,
    });
    
    app.update();
    
    // Verify Yuriko is now on battlefield and attacking
    let zones = app.world.resource::<Zones>();
    assert!(!zones.command.contains(&yuriko));
    assert!(zones.battlefield.contains(&yuriko));
    
    let attacking_status = app.world.get::<AttackingStatus>(yuriko).unwrap();
    assert!(attacking_status.attacking);
    assert_eq!(attacking_status.defending_player, Some(opponent));
    
    // Verify the attacker was returned to hand
    assert!(zones.hand.contains(&attacker));
}
}
  • Command Zone: How commanders move to and from the command zone
  • Commander Tax: How tax is applied to commanders
  • Combat: Combat phase implementation where Ninjutsu is activated

Commander Death Triggers

This document details the implementation of Commander death triggers and related mechanics in the Commander format.

Overview

In Commander, when a commander changes zones from the battlefield to any zone other than the command zone, the commander's owner has the option to move it to the command zone instead. This rule creates a special interaction with "dies" triggers, as the commander technically doesn't die (go to the graveyard) if moved to the command zone.

Rules Evolution

The Commander rules regarding death triggers have evolved over time:

  1. Pre-2020 Rule: Commanders changing zones would create a replacement effect, preventing them from ever entering the graveyard.
  2. Post-2020 Rule: Commanders now briefly touch the destination zone before being moved to the command zone as a state-based action, enabling death triggers to work.

Current Rule Implementation

Under the current rules, when a commander would leave the battlefield:

  1. The commander actually moves to the destination zone (e.g., graveyard)
  2. This movement triggers any applicable abilities (e.g., "when this creature dies")
  3. The next time state-based actions are checked, the commander's owner may choose to move it to the command zone

This creates a brief window where the commander exists in the destination zone, allowing "dies" and other zone-change triggers to function normally.

Rules Implementation

Step 1: Zone Transition Tracking

#![allow(unused)]
fn main() {
/// System to handle initial zone transitions of commanders
pub fn track_commander_zone_transitions(
    mut zone_events: EventReader<ZoneTransitionEvent>,
    mut pending_commander_moves: ResMut<PendingCommanderMoves>,
    commander_query: Query<&Commander>,
) {
    for event in zone_events.read() {
        // Only care about commanders moving from battlefield
        if event.from == Zone::Battlefield && 
           event.to != Zone::Command && 
           commander_query.contains(event.entity) {
            
            // Record that this commander might move to command zone
            pending_commander_moves.commanders.insert(
                event.entity,
                CommanderMoveInfo {
                    current_zone: event.to,
                    last_transition_time: Instant::now(),
                }
            );
        }
    }
}
}

Step 2: State-Based Action for Command Zone Option

#![allow(unused)]
fn main() {
/// State-based action system that offers command zone option
pub fn commander_zone_choice_sba(
    mut commands: Commands,
    mut pending_moves: ResMut<PendingCommanderMoves>,
    mut zone_transitions: EventWriter<ZoneTransitionEvent>,
    mut player_choices: EventWriter<PlayerChoiceEvent>,
    commanders: Query<(Entity, &Owner), With<Commander>>,
    zones: Res<Zones>,
) {
    // Review all pending commander moves during state-based action check
    for (commander, move_info) in pending_moves.commanders.iter() {
        if let Ok((entity, owner)) = commanders.get(*commander) {
            // Offer choice to move to command zone
            player_choices.send(PlayerChoiceEvent {
                player: owner.0,
                choice_type: ChoiceType::CommandZoneOption {
                    commander: entity,
                    current_zone: move_info.current_zone,
                },
                timeout: Duration::from_secs(30),
            });
        }
    }
}

/// Response handler for command zone choice
pub fn handle_command_zone_choice(
    mut commands: Commands,
    mut choice_responses: EventReader<PlayerChoiceResponse>,
    mut zone_transitions: EventWriter<ZoneTransitionEvent>,
    mut pending_moves: ResMut<PendingCommanderMoves>,
) {
    for response in choice_responses.read() {
        if let ChoiceType::CommandZoneOption { commander, current_zone } = &response.choice_type {
            if response.choice == "command_zone" {
                // Player chose to move commander to command zone
                zone_transitions.send(ZoneTransitionEvent {
                    entity: *commander,
                    from: *current_zone,
                    to: Zone::Command,
                    cause: TransitionCause::CommanderRule,
                });
            }
            
            // Remove from pending moves either way
            pending_moves.commanders.remove(commander);
        }
    }
}
}

Step 3: Death Trigger Processing

The death triggers themselves work normally since the commander actually enters the graveyard:

#![allow(unused)]
fn main() {
/// System that processes death triggers
pub fn process_death_triggers(
    mut commands: Commands,
    mut zone_events: EventReader<ZoneTransitionEvent>,
    mut triggered_abilities: EventWriter<TriggeredAbilityEvent>,
    death_triggers: Query<(Entity, &DiesTriggeredAbility, &Owner)>,
) {
    for event in zone_events.read() {
        // Check for dies triggers (battlefield to graveyard)
        if event.from == Zone::Battlefield && event.to == Zone::Graveyard {
            if let Ok((entity, ability, owner)) = death_triggers.get(event.entity) {
                // Trigger the ability
                triggered_abilities.send(TriggeredAbilityEvent {
                    source: entity,
                    ability_id: ability.id,
                    controller: owner.0,
                    trigger_cause: TriggerCause::ZoneChange {
                        entity,
                        from: event.from,
                        to: event.to,
                    },
                });
            }
        }
    }
}
}

Timing and Implementation Considerations

The timing of the commander movement is important:

  1. The death trigger must be processed before the commander moves to the command zone
  2. The state-based action check that offers the command zone option must happen after death triggers are put on the stack
  3. The commander stays in the graveyard (or other zone) until the command zone choice is made
#![allow(unused)]
fn main() {
// System ordering for proper death trigger handling
fn build_systems(app: &mut App) {
    app.add_systems(Update, (
        track_commander_zone_transitions,
        process_death_triggers,
        apply_state_based_actions,
        commander_zone_choice_sba
    ).chain());
}
}

Special Interactions

Several cards interact with commander death and zone changes in unique ways:

1. "Dies" Triggers on Commanders

Commanders with "When this creature dies" triggers will function normally:

#![allow(unused)]
fn main() {
pub fn create_elenda_the_dusk_rose() -> impl Bundle {
    (
        CardName("Elenda, the Dusk Rose".to_string()),
        // Other card components...
        Commander,
        DiesTriggeredAbility {
            id: AbilityId::new(),
            effect: Box::new(ElendaDeathEffect),
        },
    )
}
}

2. Commanders with Recursion Abilities

Some commanders have abilities that can return them from the graveyard:

#![allow(unused)]
fn main() {
pub fn create_gisa_and_geralf() -> impl Bundle {
    (
        CardName("Gisa and Geralf".to_string()),
        // Other card components...
        Commander,
        // Ability to cast zombie cards from graveyard
        ActivatedAbility {
            id: AbilityId::new(),
            cost: AbilityCost::None,
            effect: Box::new(CastZombieFromGraveyardEffect),
            timing_restriction: TimingRestriction::YourTurn,
            zone_restriction: ZoneRestriction::OnBattlefield,
        },
    )
}
}

3. Graveyard Replacement Effects

Effects that replace going to the graveyard (like Rest in Peace) interact with commanders:

#![allow(unused)]
fn main() {
pub fn handle_rest_in_peace_effect(
    mut zone_events: EventReader<ZoneTransitionEvent>,
    mut modified_zone_events: EventWriter<ModifiedZoneTransitionEvent>,
    rest_in_peace_effects: Query<&ReplacementEffect>,
) {
    for event in zone_events.read() {
        if event.to == Zone::Graveyard {
            // Check if Rest in Peace or similar effect is active
            if has_active_graveyard_replacement(&rest_in_peace_effects) {
                // This will affect commander death triggers
                modified_zone_events.send(ModifiedZoneTransitionEvent {
                    original: event.clone(),
                    modified_to: Zone::Exile,
                    cause: TransitionCause::ReplacementEffect {
                        effect_name: "Rest in Peace".to_string(),
                    },
                });
            }
        }
    }
}
}

Commander Death and State-Based Actions

The rules for commander death interact with multiple state-based actions:

#![allow(unused)]
fn main() {
pub fn apply_state_based_actions(
    mut commands: Commands,
    mut game_state: ResMut<GameState>,
    // ... other queries
) {
    // Only check when a player would receive priority
    if !should_check_sba(&game_state) {
        return;
    }
    
    // Check various state-based actions
    // ...
    
    // Process pending commander moves
    commander_zone_choice_sba(/* ... */);
    
    // Mark that we've checked SBAs
    game_state.last_sba_check = Instant::now();
}
}

User Interface Considerations

The UI for commander death requires special handling:

  1. Prompt for command zone choice must be clear and timely
  2. Visual indication of commanders in non-command zones is needed
  3. Death triggers should be shown clearly when applicable

Testing Commander Death Triggers

#![allow(unused)]
fn main() {
#[test]
fn test_commander_death_triggers() {
    let mut app = App::new();
    app.add_systems(Startup, setup_test);
    app.add_systems(Update, (
        track_commander_zone_transitions,
        process_death_triggers,
        commander_zone_choice_sba,
        handle_command_zone_choice,
    ));
    
    // Create test entities
    let player = app.world.spawn_empty().id();
    
    // Create a commander with a death trigger
    let commander = app.world.spawn((
        CardName("Test Commander".to_string()),
        Commander,
        Owner(player),
        DiesTriggeredAbility {
            id: AbilityId::new(),
            effect: Box::new(TestDeathEffect),
        },
    )).id();
    
    // Move commander from battlefield to graveyard
    app.world.send_event(ZoneTransitionEvent {
        entity: commander,
        from: Zone::Battlefield,
        to: Zone::Graveyard,
        cause: TransitionCause::Destroy,
    });
    
    app.update();
    
    // Verify death trigger happened
    let triggered = app.world.resource::<TestState>().death_trigger_happened;
    assert!(triggered, "Death trigger should have happened");
    
    // Choose to move to command zone
    app.world.send_event(PlayerChoiceResponse {
        player: player,
        choice_type: ChoiceType::CommandZoneOption {
            commander: commander,
            current_zone: Zone::Graveyard,
        },
        choice: "command_zone".to_string(),
    });
    
    app.update();
    
    // Verify commander is now in command zone
    let zones = app.world.resource::<Zones>();
    assert!(zones.command.contains(&commander));
    assert!(!zones.graveyard.contains(&commander));
}
}

Commander-Specific Cards

This document details the implementation of cards designed specifically for the Commander format.

Overview

Commander has become one of Magic: The Gathering's most popular formats, leading to the creation of cards specifically designed for it. These cards include:

  1. Cards that explicitly reference the command zone or commanders
  2. Cards designed to support multiplayer politics
  3. Cards with mechanics only found in Commander products
  4. Cards that interact with format-specific rules

Card Categories

Command Zone Interaction Cards

These cards directly interact with the command zone or commanders:

Command Zone Access

#![allow(unused)]
fn main() {
/// Component for effects that can access the command zone
#[derive(Component)]
pub struct CommandZoneAccessEffect {
    /// The type of interaction with the command zone
    pub interaction_type: CommandZoneInteraction,
}

/// Types of command zone interactions
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CommandZoneInteraction {
    /// Cast a card from the command zone (e.g., Command Beacon)
    CastFromZone,
    /// Return a card to the command zone (e.g., Leadership Vacuum)
    ReturnToZone,
    /// Copy a commander (e.g., Sakashima of a Thousand Faces)
    CopyCommander,
    /// Modify commander properties (e.g., Nevermore naming a commander)
    ModifyCommander,
}
}

Example implementation of Command Beacon:

#![allow(unused)]
fn main() {
pub fn create_command_beacon() -> impl Bundle {
    (
        CardName("Command Beacon".to_string()),
        CardType::Land,
        EntersTapped(false),
        ActivatedAbility {
            id: AbilityId::new(),
            cost: AbilityCost::Sacrifice(SacrificeCost::Self_),
            effect: Box::new(CommandBeaconEffect),
            timing_restriction: TimingRestriction::Sorcery,
            zone_restriction: ZoneRestriction::OnBattlefield,
        },
    )
}

/// Implementation of Command Beacon's effect
#[derive(Debug, Clone)]
pub struct CommandBeaconEffect;

impl AbilityEffect for CommandBeaconEffect {
    fn resolve(&self, world: &mut World, ability_ctx: &mut AbilityContext) {
        let source = ability_ctx.source;
        let controller = ability_ctx.controller;
        
        // Find commander in command zone
        if let Some(commander) = find_commander_in_command_zone(world, controller) {
            // Move commander to hand
            world.send_event(ZoneTransitionEvent {
                entity: commander,
                from: Zone::Command,
                to: Zone::Hand,
                cause: TransitionCause::Ability {
                    source,
                    ability_name: "Command Beacon".to_string(),
                },
            });
        }
    }
}
}

Commander Cost Reduction

Cards that reduce or modify the cost of casting commanders:

#![allow(unused)]
fn main() {
/// Component for effects that modify commander costs
#[derive(Component)]
pub struct CommanderCostModifier {
    /// How the cost is modified
    pub modification: CostModification,
    /// Which commanders this applies to (all or specific)
    pub target: CommanderTarget,
}

/// Implementation of Emerald Medallion (reduces cost of green spells)
pub fn create_emerald_medallion() -> impl Bundle {
    (
        CardName("Emerald Medallion".to_string()),
        CardType::Artifact,
        StaticAbility {
            id: AbilityId::new(),
            effect: Box::new(ColorCostReductionEffect(Color::Green)),
            condition: StaticCondition::Always,
        },
    )
}
}

Multiplayer Politics Cards

Cards designed for multiplayer political interactions:

#![allow(unused)]
fn main() {
/// Component for voting cards
#[derive(Component)]
pub struct VotingMechanic {
    /// The options players can vote for
    pub options: Vec<String>,
    /// What happens based on voting results
    pub resolution: VoteResolution,
}

/// Implementation of Council's Judgment
pub fn create_councils_judgment() -> impl Bundle {
    (
        CardName("Council's Judgment".to_string()),
        CardType::Sorcery,
        ManaCost::parse("{1}{W}{W}"),
        VotingMechanic {
            options: vec!["Exile".to_string()], // Each nonland permanent is an option
            resolution: VoteResolution::ExileMostVotes,
        },
    )
}

/// System that handles voting
pub fn handle_voting(
    mut commands: Commands,
    mut vote_events: EventReader<VoteEvent>,
    mut vote_results: EventWriter<VoteResultEvent>,
    voting_cards: Query<(Entity, &VotingMechanic, &Owner)>,
    players: Query<Entity, With<Player>>,
) {
    // Implementation details...
}
}

Commander-Specific Mechanics

Several mechanics were introduced specifically for Commander products:

Lieutenant

Cards with abilities that trigger if you control your commander:

#![allow(unused)]
fn main() {
/// Component for Lieutenant abilities
#[derive(Component)]
pub struct Lieutenant {
    /// The effect that happens when you control your commander
    pub effect: Box<dyn LieutenantEffect>,
}

/// Implementation of Thunderfoot Baloth
pub fn create_thunderfoot_baloth() -> impl Bundle {
    (
        CardName("Thunderfoot Baloth".to_string()),
        CardType::Creature,
        CreatureType(vec!["Beast".to_string()]),
        Power(5),
        Toughness(5),
        Lieutenant {
            effect: Box::new(ThunderfootBoostEffect),
        },
    )
}

/// System that checks for Lieutenant conditions
pub fn check_lieutenant_condition(
    mut commands: Commands,
    lieutenants: Query<(Entity, &Lieutenant, &Controller)>,
    commanders: Query<(Entity, &Commander, &Controller)>,
    mut effect_events: EventWriter<LieutenantEffectEvent>,
) {
    // For each lieutenant...
    for (lieutenant_entity, lieutenant, controller) in lieutenants.iter() {
        // Check if controller controls their commander
        let controls_commander = commanders
            .iter()
            .any(|(_, _, cmd_controller)| cmd_controller.0 == controller.0);
        
        // Apply or remove effect based on condition
        effect_events.send(LieutenantEffectEvent {
            lieutenant: lieutenant_entity,
            active: controls_commander,
        });
    }
}
}

Join Forces

Cards that allow all players to contribute mana to a spell:

#![allow(unused)]
fn main() {
/// Component for Join Forces mechanic
#[derive(Component)]
pub struct JoinForces {
    /// What happens based on mana contributed
    pub effect: Box<dyn JoinForcesEffect>,
    /// Which players can contribute
    pub contributor_restriction: ContributorRestriction,
}

/// Implementation of Minds Aglow
pub fn create_minds_aglow() -> impl Bundle {
    (
        CardName("Minds Aglow".to_string()),
        CardType::Sorcery,
        ManaCost::parse("{U}"),
        JoinForces {
            effect: Box::new(MindsAglowDrawEffect),
            contributor_restriction: ContributorRestriction::All,
        },
    )
}
}

Monarch

The Monarch mechanic introduces a special designation that players can claim, providing card advantage:

#![allow(unused)]
fn main() {
/// Component marking a player as the Monarch
#[derive(Component)]
pub struct Monarch;

/// Resource tracking the current monarch
#[derive(Resource)]
pub struct MonarchState {
    /// The current monarch, if any
    pub current_monarch: Option<Entity>,
    /// When the monarchy was last changed
    pub last_changed: Option<f32>,
}

/// System that handles the Monarch end-of-turn trigger
pub fn monarch_end_step_trigger(
    time: Res<Time>,
    monarch_state: Res<MonarchState>,
    mut turn_events: EventReader<EndStepEvent>,
    mut card_draw_events: EventWriter<DrawCardEvent>,
) {
    for event in turn_events.read() {
        if let Some(monarch) = monarch_state.current_monarch {
            if event.player == monarch {
                // Monarch draws a card at end of their turn
                card_draw_events.send(DrawCardEvent {
                    player: monarch,
                    amount: 1,
                    source: None,
                    reason: DrawReason::Ability("Monarch".to_string()),
                });
            }
        }
    }
}

/// System that transfers the Monarch when combat damage is dealt to them
pub fn monarch_combat_damage_transfer(
    mut commands: Commands,
    mut monarch_state: ResMut<MonarchState>,
    mut combat_damage_events: EventReader<CombatDamageEvent>,
    players: Query<Entity, With<Player>>,
) {
    for event in combat_damage_events.read() {
        if let Some(current_monarch) = monarch_state.current_monarch {
            if event.defending_player == current_monarch {
                // Transfer monarchy to attacking player
                if let Some(monarch_component) = commands.get_entity(current_monarch) {
                    monarch_component.remove::<Monarch>();
                }
                
                if let Some(new_monarch) = commands.get_entity(event.attacking_player_controller) {
                    new_monarch.insert(Monarch);
                    monarch_state.current_monarch = Some(event.attacking_player_controller);
                    monarch_state.last_changed = Some(time.elapsed_seconds());
                }
            }
        }
    }
}
}

Myriad

Myriad creates token copies attacking each opponent:

#![allow(unused)]
fn main() {
/// Component for the Myriad ability
#[derive(Component)]
pub struct Myriad;

/// System that handles Myriad triggers
pub fn handle_myriad_attacks(
    mut commands: Commands,
    mut declare_attacker_events: EventReader<DeclareAttackerEvent>,
    myriad_creatures: Query<Entity, With<Myriad>>,
    players: Query<Entity, With<Player>>,
    controllers: Query<&Controller>,
) {
    for event in declare_attacker_events.read() {
        if myriad_creatures.contains(event.attacker) {
            let attacker_controller = controllers.get(event.attacker).map(|c| c.0).unwrap_or_default();
            
            // Create token copies attacking each other opponent
            for potential_defender in players.iter() {
                // Skip the attacking player and the already-targeted defender
                if potential_defender == attacker_controller || potential_defender == event.defender {
                    continue;
                }
                
                // Create a token copy attacking this opponent
                let token = commands.spawn((
                    // Copy relevant components from original
                    // Add attacking status to new opponent
                    AttackingStatus {
                        defending_player: potential_defender,
                    },
                    TokenCopy {
                        original: event.attacker,
                        // Myriad tokens are exiled at end of combat
                        exile_at_end_of_combat: true,
                    },
                )).id();
                
                // More token setup...
            }
        }
    }
}
}

Melee

Melee gives a bonus based on how many opponents were attacked:

#![allow(unused)]
fn main() {
/// Component for the Melee ability
#[derive(Component)]
pub struct Melee {
    /// Bonus per opponent attacked
    pub bonus: i32,
}

/// System that calculates Melee bonuses
pub fn calculate_melee_bonuses(
    mut commands: Commands,
    mut declare_attackers_step_events: EventReader<DeclareAttackersStepEvent>,
    melee_creatures: Query<(Entity, &Melee, &Controller)>,
    mut attacking_status: Query<&AttackingStatus>,
    players: Query<Entity, With<Player>>,
) {
    for event in declare_attackers_step_events.read() {
        // For each creature with Melee...
        for (melee_entity, melee, controller) in melee_creatures.iter() {
            // Count distinct opponents attacked
            let opponents_attacked = attacking_status
                .iter()
                .filter(|status| {
                    // Only count attacks from controller's creatures
                    if let Ok(attacker_controller) = controllers.get(status.attacker) {
                        attacker_controller.0 == controller.0 && status.defending_player != controller.0
                    } else {
                        false
                    }
                })
                .map(|status| status.defending_player)
                .collect::<HashSet<Entity>>()
                .len();
                
            // Apply Melee bonus based on opponents attacked
            commands.entity(melee_entity).insert(MeleeBoost {
                power_bonus: melee.bonus * opponents_attacked as i32,
                toughness_bonus: melee.bonus * opponents_attacked as i32,
                expires_at: ExpiryTiming::EndOfTurn,
            });
        }
    }
}
}

Goad

Goad forces creatures to attack players other than you:

#![allow(unused)]
fn main() {
/// Component marking a creature as Goaded
#[derive(Component)]
pub struct Goaded {
    /// The player who applied the goad effect
    pub goaded_by: Entity,
    /// When the goad effect expires
    pub expires_at: ExpiryTiming,
}

/// System that enforces Goad restrictions during attacks
pub fn enforce_goad_restrictions(
    goaded_creatures: Query<(Entity, &Goaded)>,
    mut attack_validation_events: EventReader<ValidateAttackEvent>,
    mut attack_response_events: EventWriter<AttackValidationResponse>,
) {
    for event in attack_validation_events.read() {
        if let Ok((_, goaded)) = goaded_creatures.get(event.attacker) {
            // If attacking the player who goaded this creature, attack is invalid
            if event.defender == goaded.goaded_by {
                attack_response_events.send(AttackValidationResponse {
                    attacker: event.attacker,
                    defender: event.defender,
                    is_valid: false,
                    reason: "This creature is goaded and must attack a different player if able".to_string(),
                });
            }
        }
    }
}

/// System that enforces Goad requirements to attack if able
pub fn enforce_goad_attack_requirement(
    goaded_creatures: Query<(Entity, &Goaded, &CanAttack, &Controller)>,
    mut attack_requirement_events: EventReader<AttackRequirementCheckEvent>,
    mut attack_required_events: EventWriter<AttackRequiredEvent>,
    players: Query<Entity, With<Player>>,
) {
    for event in attack_requirement_events.read() {
        for (goaded_entity, goaded, can_attack, controller) in goaded_creatures.iter() {
            // If creature can attack and is controlled by current player
            if can_attack.0 && controller.0 == event.attacking_player {
                // Find valid attack targets (not the player who goaded)
                let valid_targets: Vec<Entity> = players
                    .iter()
                    .filter(|player| *player != goaded.goaded_by && *player != controller.0)
                    .collect();
                
                // If there are valid targets, this creature must attack
                if !valid_targets.is_empty() {
                    attack_required_events.send(AttackRequiredEvent {
                        creature: goaded_entity,
                        valid_targets,
                    });
                }
            }
        }
    }
}
}

Commander-Specific Cycles and Card Groups

Medallion Cycle

The Medallion cycle (Ruby Medallion, Sapphire Medallion, etc.) reduces costs for spells of specific colors:

#![allow(unused)]
fn main() {
pub fn create_ruby_medallion() -> impl Bundle {
    (
        CardName("Ruby Medallion".to_string()),
        CardType::Artifact,
        StaticAbility {
            id: AbilityId::new(),
            effect: Box::new(ColorCostReductionEffect(Color::Red)),
            condition: StaticCondition::Always,
        },
    )
}
}

Commander's Plate

A special equipment that gives protection from colors outside your commander's color identity:

#![allow(unused)]
fn main() {
pub fn create_commanders_plate() -> impl Bundle {
    (
        CardName("Commander's Plate".to_string()),
        CardType::Artifact,
        ArtifactType(vec!["Equipment".to_string()]),
        EquipCost(ManaCost::parse("{3}")),
        StaticAbility {
            id: AbilityId::new(),
            effect: Box::new(CommandersPlateEffect),
            condition: StaticCondition::IsEquipped,
        },
    )
}

#[derive(Debug, Clone)]
pub struct CommandersPlateEffect;

impl StaticEffect for CommandersPlateEffect {
    fn apply(&self, world: &mut World, source: Entity, target: Entity) {
        // Get equipped creature's controller
        let controller = world.get::<Controller>(target).unwrap().0;
        
        // Get controller's commanders' color identity
        let commander_identity = get_commander_color_identity(world, controller);
        
        // Grant protection from colors outside that identity
        let protection_colors = ALL_COLORS.iter()
            .filter(|color| !commander_identity.contains(**color))
            .copied()
            .collect();
        
        world.entity_mut(target).insert(Protection {
            protection_from: ProtectionType::Colors(protection_colors),
            source,
        });
        
        // Also grant stat boosts
        world.entity_mut(target).insert(StatBoost {
            power: 3,
            toughness: 3,
            source,
        });
    }
}
}

Testing Commander-Specific Cards

Testing commander-specific cards requires special test fixtures and scenarios:

#![allow(unused)]
fn main() {
#[test]
fn test_command_beacon() {
    let mut app = App::new();
    setup_test_game(&mut app);
    
    let player = app.world.spawn(Player).id();
    
    // Create a commander with tax
    let commander = app.world.spawn((
        CardName("Test Commander".to_string()),
        Commander,
        InCommandZone(player),
        CommanderCastCount(2),  // Commander has been cast twice before
    )).id();
    
    // Create Command Beacon
    let beacon = app.world.spawn(create_command_beacon()).id();
    
    // Use Command Beacon's ability
    use_ability(beacon, player, &mut app.world);
    
    // Verify commander moved to hand
    assert!(has_component::<InHand>(&app.world, commander));
    assert!(!has_component::<InCommandZone>(&app.world, commander));
    
    // Verify commander can be cast without tax
    let cast_cost = calculate_commander_cast_cost(&app.world, commander, player);
    assert_eq!(cast_cost, commander_base_cost(&app.world, commander));
}
}

UI Considerations

Commander-specific cards require special UI treatment:

  1. Command zone interactions need visual clarity
  2. Political mechanics need multiplayer-aware UI elements
  3. Special effects like Monarch need distinctive visual indicators
#![allow(unused)]
fn main() {
/// UI component for displaying monarchy status
#[derive(Component)]
pub struct MonarchyIndicator {
    pub entity: Entity,
}

/// System to update monarchy UI
pub fn update_monarchy_ui(
    monarch_state: Res<MonarchState>,
    mut indicators: Query<(&mut Visibility, &MonarchyIndicator)>,
) {
    for (mut visibility, indicator) in indicators.iter_mut() {
        visibility.is_visible = monarch_state.current_monarch == Some(indicator.entity);
    }
}
}

Commander Preconstructed Deck Integration

Our system includes support for preconstructed Commander decks:

#![allow(unused)]
fn main() {
/// Resource containing preconstructed Commander deck definitions
#[derive(Resource)]
pub struct PreconstructedDecks {
    pub decks: HashMap<String, PreconstructedDeckData>,
}

/// Data for a preconstructed Commander deck
#[derive(Clone, Debug)]
pub struct PreconstructedDeckData {
    /// Deck name
    pub name: String,
    /// The set/product the deck is from
    pub product: String,
    /// Year released
    pub year: u32,
    /// Primary commander
    pub commander: String,
    /// Secondary commander/partner (if any)
    pub partner: Option<String>,
    /// List of all cards in the deck
    pub card_list: Vec<String>,
    /// Deck color identity
    pub color_identity: ColorIdentity,
    /// Deck theme or strategy
    pub theme: String,
}

/// Function to load all preconstructed Commander decks
pub fn load_preconstructed_decks() -> PreconstructedDecks {
    // Load deck definitions from files
    // ...
}
}

Conclusion

Commander-specific cards are a vital part of the format's identity. By implementing these cards correctly, we ensure that our game engine provides an authentic Commander experience. The implementation must balance rules accuracy with performance considerations, especially for complex political mechanics in multiplayer games.

Multiplayer Politics Tests

This section contains tests related to the multiplayer politics mechanics in the Commander format.

Tests

Deal Testing

Tests for mechanics that involve deals and agreements between players, including:

  • Promise effects ("I'll do X if you do Y")
  • Tempting offer cards
  • Consequence tracking for deal violations
  • UI/UX for deal making

Monarch Testing

Tests for the Monarch mechanic, including:

  • Monarch status transitions between players
  • End-of-turn triggers for the Monarch
  • Combat interactions with the Monarch status
  • Multiple Monarch-granting effects

Voting Testing

Tests for the Council's Dilemma and other voting mechanics, including:

  • Vote counting
  • Vote option resolution
  • Tie-breaking
  • Effects that modify voting

These tests ensure that the political elements unique to multiplayer Commander function correctly within the game engine.

Deal System Testing

Overview

This document details the testing approach for the Deal system within Rummage's multiplayer Commander implementation. Deals are a key political mechanic allowing players to make formal agreements with benefits and consequences, adding strategic depth to multiplayer interaction.

Deal Creation Tests

Tests for the creation and initialization of deals:

#![allow(unused)]
fn main() {
#[test]
fn test_deal_creation() {
    let mut app = App::new();
    app.add_plugins(MinimalPlugins)
       .add_plugin(PoliticsPlugin);
       
    // Setup players
    let players = setup_four_player_game(&mut app);
    
    // Create a simple deal
    let deal_id = app.world.send_event(CreateDealEvent {
        proposer: players[0],
        target: players[1],
        terms: vec![
            DealTerm::AttackRestriction {
                restricted_player: players[1],
                duration: DealDuration::Turns(2),
            },
            DealTerm::NonAggressionPact {
                duration: DealDuration::Turns(2),
            },
        ],
        rewards: vec![
            DealReward::DrawCards { count: 1 },
        ],
        penalties: vec![
            DealPenalty::LifeLoss { amount: 5 },
        ],
        duration: DealDuration::Turns(2),
    });
    app.update();
    
    // Verify deal was created and is in pending state
    let deal_registry = app.world.resource::<DealRegistry>();
    let deal = deal_registry.get_deal(deal_id).unwrap();
    
    assert_eq!(deal.proposer, players[0]);
    assert_eq!(deal.target, players[1]);
    assert_eq!(deal.terms.len(), 2);
    assert_eq!(deal.status, DealStatus::Pending);
}

#[test]
fn test_deal_term_validation() {
    let mut app = App::new();
    app.add_plugins(MinimalPlugins)
       .add_plugin(PoliticsPlugin);
       
    // Setup players
    let players = setup_four_player_game(&mut app);
    
    // Create a deal with invalid terms (can't restrict a player not in the deal)
    let invalid_deal_id = app.world.send_event(CreateDealEvent {
        proposer: players[0],
        target: players[1],
        terms: vec![
            DealTerm::AttackRestriction {
                restricted_player: players[2], // Invalid: players[2] not part of deal
                duration: DealDuration::Turns(2),
            },
        ],
        rewards: vec![],
        penalties: vec![],
        duration: DealDuration::Turns(2),
    });
    app.update();
    
    // Verify deal was rejected
    let deal_registry = app.world.resource::<DealRegistry>();
    let invalid_deal = deal_registry.get_deal(invalid_deal_id).unwrap();
    
    assert_eq!(invalid_deal.status, DealStatus::Rejected);
    assert_eq!(
        invalid_deal.rejection_reason, 
        Some(DealRejectionReason::InvalidTerms)
    );
    
    // Create a valid deal
    let valid_deal_id = app.world.send_event(CreateDealEvent {
        proposer: players[0],
        target: players[1],
        terms: vec![
            DealTerm::AttackRestriction {
                restricted_player: players[1], // Valid: players[1] is target
                duration: DealDuration::Turns(2),
            },
        ],
        rewards: vec![],
        penalties: vec![],
        duration: DealDuration::Turns(2),
    });
    app.update();
    
    // Verify valid deal is pending
    let valid_deal = deal_registry.get_deal(valid_deal_id).unwrap();
    assert_eq!(valid_deal.status, DealStatus::Pending);
}

#[test]
fn test_deal_duration_validation() {
    let mut app = App::new();
    app.add_plugins(MinimalPlugins)
       .add_plugin(PoliticsPlugin);
       
    // Setup players
    let players = setup_four_player_game(&mut app);
    
    // Create a deal with excessively long duration
    let long_deal_id = app.world.send_event(CreateDealEvent {
        proposer: players[0],
        target: players[1],
        terms: vec![
            DealTerm::NonAggressionPact {
                duration: DealDuration::Turns(20), // Too long
            },
        ],
        rewards: vec![],
        penalties: vec![],
        duration: DealDuration::Turns(20), // Exceeds maximum allowed duration
    });
    app.update();
    
    // Verify deal was rejected
    let deal_registry = app.world.resource::<DealRegistry>();
    let long_deal = deal_registry.get_deal(long_deal_id).unwrap();
    
    assert_eq!(long_deal.status, DealStatus::Rejected);
    assert_eq!(
        long_deal.rejection_reason, 
        Some(DealRejectionReason::ExcessiveDuration)
    );
}
}

Deal Negotiation Tests

Testing the deal negotiation mechanics:

#![allow(unused)]
fn main() {
#[test]
fn test_deal_acceptance() {
    let mut app = App::new();
    app.add_plugins(MinimalPlugins)
       .add_plugin(PoliticsPlugin);
       
    // Setup players
    let players = setup_four_player_game(&mut app);
    
    // Create a deal
    let deal_id = app.world.send_event(CreateDealEvent {
        proposer: players[0],
        target: players[1],
        terms: vec![
            DealTerm::NonAggressionPact {
                duration: DealDuration::Turns(2),
            },
        ],
        rewards: vec![
            DealReward::DrawCards { count: 1 },
        ],
        penalties: vec![],
        duration: DealDuration::Turns(2),
    });
    app.update();
    
    // Target accepts the deal
    app.world.send_event(RespondToDealEvent {
        responder: players[1],
        deal_id,
        response: DealResponse::Accept,
    });
    app.update();
    
    // Verify deal is now active
    let deal_registry = app.world.resource::<DealRegistry>();
    let deal = deal_registry.get_deal(deal_id).unwrap();
    
    assert_eq!(deal.status, DealStatus::Active);
    
    // Verify reward was given
    let player1_hand_size = get_player_hand_size(&app, players[1]);
    assert_eq!(player1_hand_size, 8); // Assuming starting hand size of 7 + 1 from reward
}

#[test]
fn test_deal_rejection() {
    let mut app = App::new();
    app.add_plugins(MinimalPlugins)
       .add_plugin(PoliticsPlugin);
       
    // Setup players
    let players = setup_four_player_game(&mut app);
    
    // Create a deal
    let deal_id = app.world.send_event(CreateDealEvent {
        proposer: players[0],
        target: players[1],
        terms: vec![
            DealTerm::NonAggressionPact {
                duration: DealDuration::Turns(2),
            },
        ],
        rewards: vec![],
        penalties: vec![],
        duration: DealDuration::Turns(2),
    });
    app.update();
    
    // Target rejects the deal
    app.world.send_event(RespondToDealEvent {
        responder: players[1],
        deal_id,
        response: DealResponse::Reject,
    });
    app.update();
    
    // Verify deal is now rejected
    let deal_registry = app.world.resource::<DealRegistry>();
    let deal = deal_registry.get_deal(deal_id).unwrap();
    
    assert_eq!(deal.status, DealStatus::Rejected);
    assert_eq!(deal.rejection_reason, Some(DealRejectionReason::TargetRejected));
}

#[test]
fn test_deal_counter_proposal() {
    let mut app = App::new();
    app.add_plugins(MinimalPlugins)
       .add_plugin(PoliticsPlugin);
       
    // Setup players
    let players = setup_four_player_game(&mut app);
    
    // Create a deal
    let deal_id = app.world.send_event(CreateDealEvent {
        proposer: players[0],
        target: players[1],
        terms: vec![
            DealTerm::AttackRestriction {
                restricted_player: players[1],
                duration: DealDuration::Turns(3),
            },
        ],
        rewards: vec![
            DealReward::DrawCards { count: 1 },
        ],
        penalties: vec![],
        duration: DealDuration::Turns(3),
    });
    app.update();
    
    // Target makes a counter-proposal
    let counter_proposal = DealCounterProposal {
        terms: vec![
            DealTerm::AttackRestriction {
                restricted_player: players[1],
                duration: DealDuration::Turns(2), // Shorter duration
            },
        ],
        rewards: vec![
            DealReward::DrawCards { count: 2 }, // More cards
        ],
        penalties: vec![],
        duration: DealDuration::Turns(2), // Shorter duration
    };
    
    app.world.send_event(RespondToDealEvent {
        responder: players[1],
        deal_id,
        response: DealResponse::CounterProposal(counter_proposal),
    });
    app.update();
    
    // Verify original deal is now countered
    let deal_registry = app.world.resource::<DealRegistry>();
    let original_deal = deal_registry.get_deal(deal_id).unwrap();
    assert_eq!(original_deal.status, DealStatus::Countered);
    
    // Find the counter proposal
    let counter_deals: Vec<_> = deal_registry.deals_iter()
        .filter(|d| d.status == DealStatus::Pending && d.proposer == players[1] && d.target == players[0])
        .collect();
    
    assert_eq!(counter_deals.len(), 1);
    let counter_deal = &counter_deals[0];
    
    // Verify counter deal has the modified terms
    assert_eq!(counter_deal.rewards.len(), 1);
    if let DealReward::DrawCards { count } = counter_deal.rewards[0] {
        assert_eq!(count, 2);
    } else {
        panic!("Expected DrawCards reward");
    }
    
    // Proposer accepts counter-proposal
    app.world.send_event(RespondToDealEvent {
        responder: players[0],
        deal_id: counter_deal.id,
        response: DealResponse::Accept,
    });
    app.update();
    
    // Verify counter deal is now active
    let updated_counter_deal = deal_registry.get_deal(counter_deal.id).unwrap();
    assert_eq!(updated_counter_deal.status, DealStatus::Active);
}
}

Deal Enforcement Tests

Testing the enforcement and violation of deals:

#![allow(unused)]
fn main() {
#[test]
fn test_deal_enforcement_attack_restriction() {
    let mut app = App::new();
    app.add_plugins(MinimalPlugins)
       .add_plugin(PoliticsPlugin)
       .add_plugin(CombatPlugin);
       
    // Setup players
    let players = setup_four_player_game(&mut app);
    
    // Create player creatures
    let attacker = app.world.spawn((
        Creature::default(),
        Permanent { controller: players[1], ..Default::default() },
    )).id();
    
    // Create a deal with attack restriction
    let deal_id = app.world.send_event(CreateDealEvent {
        proposer: players[0],
        target: players[1],
        terms: vec![
            DealTerm::AttackRestriction {
                restricted_player: players[0], // Target can't attack proposer
                duration: DealDuration::Turns(2),
            },
        ],
        rewards: vec![],
        penalties: vec![
            DealPenalty::LifeLoss { amount: 3 },
        ],
        duration: DealDuration::Turns(2),
    });
    app.update();
    
    // Target accepts the deal
    app.world.send_event(RespondToDealEvent {
        responder: players[1],
        deal_id,
        response: DealResponse::Accept,
    });
    app.update();
    
    // Capture life total before violation
    let initial_life = app.world.get::<Player>(players[1]).unwrap().life_total;
    
    // Target attempts to attack proposer (violating deal)
    app.world.send_event(DeclareAttackerEvent {
        attacker,
        defender: players[0],
    });
    app.update();
    
    // Verify deal violation was detected
    let deal_registry = app.world.resource::<DealRegistry>();
    let deal = deal_registry.get_deal(deal_id).unwrap();
    
    assert!(deal.violations.contains(&DealViolation {
        violator: players[1],
        violation_type: ViolationType::AttackRestriction,
        turn: app.world.resource::<TurnState>().turn_number,
    }));
    
    // Verify penalty was applied
    let new_life = app.world.get::<Player>(players[1]).unwrap().life_total;
    assert_eq!(new_life, initial_life - 3);
}

#[test]
fn test_deal_expiration() {
    let mut app = App::new();
    app.add_plugins(MinimalPlugins)
       .add_plugin(PoliticsPlugin)
       .add_plugin(TurnStructurePlugin);
       
    // Setup players
    let players = setup_four_player_game(&mut app);
    
    // Create a deal with short duration
    let deal_id = app.world.send_event(CreateDealEvent {
        proposer: players[0],
        target: players[1],
        terms: vec![
            DealTerm::NonAggressionPact {
                duration: DealDuration::Turns(1),
            },
        ],
        rewards: vec![],
        penalties: vec![],
        duration: DealDuration::Turns(1), // 1 turn duration
    });
    app.update();
    
    // Target accepts the deal
    app.world.send_event(RespondToDealEvent {
        responder: players[1],
        deal_id,
        response: DealResponse::Accept,
    });
    app.update();
    
    // Verify deal is active
    let deal_registry = app.world.resource::<DealRegistry>();
    let deal_before = deal_registry.get_deal(deal_id).unwrap();
    assert_eq!(deal_before.status, DealStatus::Active);
    
    // Advance turn
    advance_turn(&mut app);
    app.update();
    
    // Verify deal is now expired
    let deal_registry = app.world.resource::<DealRegistry>();
    let deal_after = deal_registry.get_deal(deal_id).unwrap();
    assert_eq!(deal_after.status, DealStatus::Expired);
}

#[test]
fn test_deal_auto_termination_on_player_loss() {
    let mut app = App::new();
    app.add_plugins(MinimalPlugins)
       .add_plugin(PoliticsPlugin);
       
    // Setup players
    let players = setup_four_player_game(&mut app);
    
    // Create and activate a deal
    let deal_id = create_and_accept_deal(&mut app, players[0], players[1]);
    
    // Verify deal is active
    let deal_registry = app.world.resource::<DealRegistry>();
    let deal_before = deal_registry.get_deal(deal_id).unwrap();
    assert_eq!(deal_before.status, DealStatus::Active);
    
    // Player leaves game
    app.world.send_event(PlayerEliminatedEvent { player: players[0] });
    app.update();
    
    // Verify deal is now terminated
    let deal_registry = app.world.resource::<DealRegistry>();
    let deal_after = deal_registry.get_deal(deal_id).unwrap();
    assert_eq!(deal_after.status, DealStatus::Terminated);
    assert_eq!(
        deal_after.termination_reason, 
        Some(DealTerminationReason::PlayerEliminated)
    );
}
}

Deal History and Reputation Tests

Testing deal history tracking and reputation systems:

#![allow(unused)]
fn main() {
#[test]
fn test_deal_history_tracking() {
    let mut app = App::new();
    app.add_plugins(MinimalPlugins)
       .add_plugin(PoliticsPlugin);
       
    // Setup players
    let players = setup_four_player_game(&mut app);
    
    // Create and activate multiple deals
    let deal1_id = create_and_accept_deal(&mut app, players[0], players[1]);
    let deal2_id = create_and_accept_deal(&mut app, players[1], players[2]);
    let deal3_id = create_and_accept_deal(&mut app, players[0], players[2]);
    
    // Player 2 violates deal with player 0
    simulate_deal_violation(&mut app, deal3_id, players[2]);
    
    // Get deal history
    let history = app.world.resource::<DealHistory>();
    
    // Check player 0's history
    let player0_history = history.get_player_history(players[0]);
    assert_eq!(player0_history.deals_proposed, 2);
    assert_eq!(player0_history.deals_honored, 2); // Both active or not violated yet
    
    // Check player 2's history
    let player2_history = history.get_player_history(players[2]);
    assert_eq!(player2_history.deals_proposed, 0);
    assert_eq!(player2_history.deals_accepted, 2);
    assert_eq!(player2_history.deals_violated, 1);
}

#[test]
fn test_reputation_system() {
    let mut app = App::new();
    app.add_plugins(MinimalPlugins)
       .add_plugin(PoliticsPlugin);
       
    // Setup players
    let players = setup_four_player_game(&mut app);
    
    // Create and accept several deals
    let deal1_id = create_and_accept_deal(&mut app, players[0], players[1]);
    let deal2_id = create_and_accept_deal(&mut app, players[0], players[2]);
    let deal3_id = create_and_accept_deal(&mut app, players[0], players[3]);
    
    // Player 3 violates deal
    simulate_deal_violation(&mut app, deal3_id, players[3]);
    
    // Check reputations
    let reputation_system = app.world.resource::<ReputationSystem>();
    
    let player0_rep = reputation_system.get_reputation(players[0]);
    let player1_rep = reputation_system.get_reputation(players[1]);
    let player3_rep = reputation_system.get_reputation(players[3]);
    
    // Player 0 should have good reputation (keeps deals)
    assert!(player0_rep.score > 0.0);
    
    // Player 1 should have neutral/positive reputation (honors deals)
    assert!(player1_rep.score >= 0.0);
    
    // Player 3 should have negative reputation (violated deal)
    assert!(player3_rep.score < 0.0);
    
    // Test reputation effects on deal proposals
    // Player 3 (bad reputation) tries to make a deal
    let deal4_id = app.world.send_event(CreateDealEvent {
        proposer: players[3],
        target: players[1],
        terms: vec![
            DealTerm::NonAggressionPact {
                duration: DealDuration::Turns(2),
            },
        ],
        rewards: vec![],
        penalties: vec![],
        duration: DealDuration::Turns(2),
    });
    app.update();
    
    // Target is more likely to reject due to proposer's reputation
    let ai_decision = app.world.resource::<AiDealSystem>()
        .evaluate_deal_proposal(players[1], deal4_id);
    
    // AI should factor in reputation (exact values depend on implementation)
    assert!(ai_decision.trust_factor < 0.5);
}
}

Integration Tests

Testing deal system integration with other game systems:

#![allow(unused)]
fn main() {
#[test]
fn test_deal_integration_with_combat() {
    let mut app = App::new();
    app.add_plugins(MinimalPlugins)
       .add_plugin(PoliticsPlugin)
       .add_plugin(CombatPlugin);
       
    // Setup players and creatures
    let players = setup_four_player_game(&mut app);
    
    let creature1 = spawn_test_creature(&mut app, 2, 2, players[0]);
    let creature2 = spawn_test_creature(&mut app, 3, 3, players[1]);
    
    // Create non-aggression pact
    let deal_id = app.world.send_event(CreateDealEvent {
        proposer: players[0],
        target: players[1],
        terms: vec![
            DealTerm::NonAggressionPact {
                duration: DealDuration::Turns(2),
            },
        ],
        rewards: vec![],
        penalties: vec![],
        duration: DealDuration::Turns(2),
    });
    app.update();
    
    // Accept deal
    app.world.send_event(RespondToDealEvent {
        responder: players[1],
        deal_id,
        response: DealResponse::Accept,
    });
    app.update();
    
    // Try to declare attack between deal participants
    app.world.send_event(DeclareAttackerEvent {
        attacker: creature1,
        defender: players[1],
    });
    app.update();
    
    // Verify combat restriction was enforced
    let combat_state = app.world.resource::<CombatState>();
    assert!(!combat_state.is_attacking(creature1));
    
    // But can still attack other players
    app.world.send_event(DeclareAttackerEvent {
        attacker: creature1,
        defender: players[2], // Not part of deal
    });
    app.update();
    
    // Verify attack was allowed
    let combat_state = app.world.resource::<CombatState>();
    assert!(combat_state.is_attacking(creature1));
    assert_eq!(combat_state.get_defender(creature1), Some(players[2]));
}

#[test]
fn test_deal_integration_with_card_effects() {
    let mut app = App::new();
    app.add_plugins(MinimalPlugins)
       .add_plugin(PoliticsPlugin);
       
    // Setup players
    let players = setup_four_player_game(&mut app);
    
    // Create "deal breaker" card
    let deal_breaker = app.world.spawn((
        Card::default(),
        DealBreakerEffect,
    )).id();
    
    // Create and accept a deal
    let deal_id = create_and_accept_deal(&mut app, players[0], players[1]);
    
    // Verify deal is active
    let deal_registry = app.world.resource::<DealRegistry>();
    let deal_before = deal_registry.get_deal(deal_id).unwrap();
    assert_eq!(deal_before.status, DealStatus::Active);
    
    // Cast deal breaker card (e.g., "Council's Judgment")
    app.world.send_event(CastCardEvent {
        caster: players[2],
        card: deal_breaker,
        targets: vec![EntityTarget::Deal(deal_id)],
    });
    app.update();
    
    // Resolve card effect
    app.world.send_event(ResolveCardEffectEvent {
        card: deal_breaker,
    });
    app.update();
    
    // Verify deal was terminated
    let deal_registry = app.world.resource::<DealRegistry>();
    let deal_after = deal_registry.get_deal(deal_id).unwrap();
    assert_eq!(deal_after.status, DealStatus::Terminated);
    assert_eq!(
        deal_after.termination_reason, 
        Some(DealTerminationReason::CardEffect)
    );
}
}

UI and Notification Tests

Testing UI and notification aspects of the deal system:

#![allow(unused)]
fn main() {
#[test]
fn test_deal_ui_representation() {
    let mut app = App::new();
    app.add_plugins(MinimalPlugins)
       .add_plugin(PoliticsPlugin)
       .add_plugin(UiPlugin);
       
    // Setup players
    let players = setup_four_player_game(&mut app);
    
    // Create a deal
    let deal_id = app.world.send_event(CreateDealEvent {
        proposer: players[0],
        target: players[1],
        terms: vec![
            DealTerm::NonAggressionPact {
                duration: DealDuration::Turns(2),
            },
        ],
        rewards: vec![],
        penalties: vec![],
        duration: DealDuration::Turns(2),
    });
    app.update();
    
    // Verify deal UI elements were created
    let deal_ui_elements = app.world.query_filtered::<Entity, With<DealUiElement>>()
        .iter(&app.world)
        .collect::<Vec<_>>();
    
    assert!(!deal_ui_elements.is_empty());
    
    // Check for proposal notification
    let notifications = app.world.query_filtered::<&Notification, With<DealProposalNotification>>()
        .iter(&app.world)
        .collect::<Vec<_>>();
    
    assert!(!notifications.is_empty());
    assert!(notifications[0].message.contains("proposed a deal"));
    
    // Accept deal
    app.world.send_event(RespondToDealEvent {
        responder: players[1],
        deal_id,
        response: DealResponse::Accept,
    });
    app.update();
    
    // Check for acceptance notification
    let acceptance_notifications = app.world.query_filtered::<&Notification, With<DealAcceptanceNotification>>()
        .iter(&app.world)
        .collect::<Vec<_>>();
    
    assert!(!acceptance_notifications.is_empty());
    assert!(acceptance_notifications[0].message.contains("accepted"));
    
    // Verify active deal indicator visible
    let active_deal_indicators = app.world.query_filtered::<Entity, (With<ActiveDealIndicator>, With<Parent>)>()
        .iter(&app.world)
        .collect::<Vec<_>>();
    
    assert!(!active_deal_indicators.is_empty());
}

#[test]
fn test_deal_violation_notifications() {
    let mut app = App::new();
    app.add_plugins(MinimalPlugins)
       .add_plugin(PoliticsPlugin)
       .add_plugin(UiPlugin);
       
    // Setup players
    let players = setup_four_player_game(&mut app);
    
    // Create and accept a deal
    let deal_id = create_and_accept_deal(&mut app, players[0], players[1]);
    
    // Simulate deal violation
    simulate_deal_violation(&mut app, deal_id, players[1]);
    
    // Check for violation notification
    let violation_notifications = app.world.query_filtered::<&Notification, With<DealViolationNotification>>()
        .iter(&app.world)
        .collect::<Vec<_>>();
    
    assert!(!violation_notifications.is_empty());
    assert!(violation_notifications[0].message.contains("violated"));
}
}

Helper Functions

#![allow(unused)]
fn main() {
/// Creates a standard deal and accepts it
fn create_and_accept_deal(app: &mut App, proposer: Entity, target: Entity) -> DealId {
    // Create deal
    let deal_id = app.world.send_event(CreateDealEvent {
        proposer,
        target,
        terms: vec![
            DealTerm::NonAggressionPact {
                duration: DealDuration::Turns(2),
            },
        ],
        rewards: vec![],
        penalties: vec![],
        duration: DealDuration::Turns(2),
    });
    app.update();
    
    // Accept deal
    app.world.send_event(RespondToDealEvent {
        responder: target,
        deal_id,
        response: DealResponse::Accept,
    });
    app.update();
    
    deal_id
}

/// Simulates violating a deal
fn simulate_deal_violation(app: &mut App, deal_id: DealId, violator: Entity) {
    // Get deal
    let deal_registry = app.world.resource::<DealRegistry>();
    let deal = deal_registry.get_deal(deal_id).unwrap();
    
    // Determine violation type based on terms
    let violation_type = if let Some(DealTerm::NonAggressionPact { .. }) = deal.terms.first() {
        ViolationType::NonAggressionViolation
    } else if let Some(DealTerm::AttackRestriction { .. }) = deal.terms.first() {
        ViolationType::AttackRestriction
    } else {
        ViolationType::Generic
    };
    
    // Trigger violation
    app.world.send_event(DealViolationEvent {
        deal_id,
        violator,
        violation_type,
    });
    app.update();
}

/// Creates a test creature
fn spawn_test_creature(app: &mut App, power: i32, toughness: i32, controller: Entity) -> Entity {
    app.world.spawn((
        Creature {
            power,
            toughness,
            ..Default::default()
        },
        Permanent {
            controller,
            ..Default::default()
        },
    )).id()
}
}

Conclusion

The Deal System adds significant depth to political interactions in Commander. These tests ensure that deals are properly created, negotiated, enforced, and terminated under all relevant circumstances, while accurately tracking player reputation based on deal history.

The Monarch Mechanic Testing

Overview

This document details the testing approach for the Monarch game mechanic within the Commander format. The Monarch is a special designation that can be passed between players and provides card advantage through its "draw a card at the beginning of your end step" effect.

Test Components

Monarch Assignment Tests

Testing the ways a player can become the Monarch:

#![allow(unused)]
fn main() {
#[test]
fn test_initial_monarch_assignment() {
    let mut app = App::new();
    app.add_plugins(MinimalPlugins)
       .add_plugin(PoliticsPlugin);
       
    // Create a test player
    let player = app.world.spawn(Player::default()).id();
    
    // Assign monarch to player
    app.world.send_event(AssignMonarchEvent { player });
    app.update();
    
    // Verify player is now the monarch
    let monarch = app.world.resource::<MonarchState>();
    assert_eq!(monarch.current_monarch, Some(player));
}

#[test]
fn test_monarch_assignment_via_card_effect() {
    let mut app = App::new();
    app.add_plugins(MinimalPlugins)
       .add_plugin(PoliticsPlugin);
       
    // Create test players
    let player1 = app.world.spawn(Player::default()).id();
    let player2 = app.world.spawn(Player::default()).id();
    
    // Create a monarch-granting card (e.g., "Court of Grace")
    let monarch_card = app.world.spawn((
        Card::default(),
        MonarchGrantingComponent,
    )).id();
    
    // Assign to player1
    give_card_to_player(&mut app, monarch_card, player1);
    
    // Cast the card
    cast_card(&mut app, player1, monarch_card);
    app.update();
    
    // Verify player1 is now the monarch
    let monarch = app.world.resource::<MonarchState>();
    assert_eq!(monarch.current_monarch, Some(player1));
    
    // Test changing the monarch to player2
    app.world.send_event(AssignMonarchEvent { player: player2 });
    app.update();
    
    // Verify player2 is now the monarch
    let monarch = app.world.resource::<MonarchState>();
    assert_eq!(monarch.current_monarch, Some(player2));
}
}

Combat-Based Monarch Transfer Tests

Test monarch transfer through combat damage:

#![allow(unused)]
fn main() {
#[test]
fn test_monarch_transfer_through_combat() {
    let mut app = App::new();
    app.add_plugins(MinimalPlugins)
       .add_plugin(PoliticsPlugin)
       .add_plugin(CombatPlugin);
       
    // Create test players
    let player1 = app.world.spawn(Player::default()).id();
    let player2 = app.world.spawn(Player::default()).id();
    
    // Make player1 the monarch
    app.world.send_event(AssignMonarchEvent { player: player1 });
    app.update();
    
    // Create creature controlled by player2
    let creature = app.world.spawn((
        Creature::default(),
        Permanent { controller: player2, ..Default::default() },
    )).id();
    
    // Simulate combat damage to player1
    app.world.send_event(CombatDamageEvent {
        source: creature,
        target: player1,
        amount: 2,
    });
    app.update();
    
    // Verify monarch transferred to player2
    let monarch = app.world.resource::<MonarchState>();
    assert_eq!(monarch.current_monarch, Some(player2));
}
}

Monarch Effect Tests

Testing the effects of being the monarch:

#![allow(unused)]
fn main() {
#[test]
fn test_monarch_card_draw_effect() {
    let mut app = App::new();
    app.add_plugins(MinimalPlugins)
       .add_plugin(PoliticsPlugin)
       .add_plugin(TurnStructurePlugin);
       
    // Create test player
    let player = app.world.spawn(Player::default()).id();
    
    // Make player the monarch
    app.world.send_event(AssignMonarchEvent { player });
    app.update();
    
    // Get initial hand size
    let initial_hand_size = get_player_hand_size(&app, player);
    
    // Simulate end step for monarch
    app.world.resource_mut::<TurnState>().current_player = player;
    app.world.resource_mut::<TurnState>().current_phase = Phase::End;
    app.update();
    
    // Trigger end step effects
    app.world.send_event(PhaseEndEvent { phase: Phase::End });
    app.update();
    
    // Verify player drew a card
    let new_hand_size = get_player_hand_size(&app, player);
    assert_eq!(new_hand_size, initial_hand_size + 1);
}
}

Edge Case Tests

Testing edge cases and unusual interactions:

#![allow(unused)]
fn main() {
#[test]
fn test_monarch_persistence_across_turns() {
    let mut app = App::new();
    app.add_plugins(MinimalPlugins)
       .add_plugin(PoliticsPlugin)
       .add_plugin(TurnStructurePlugin);
       
    // Setup multi-player game
    let players = setup_four_player_game(&mut app);
    
    // Make first player the monarch
    app.world.send_event(AssignMonarchEvent { player: players[0] });
    app.update();
    
    // Simulate full turn cycle
    for _ in 0..4 {
        advance_turn(&mut app);
    }
    
    // Verify player is still monarch after full cycle
    let monarch = app.world.resource::<MonarchState>();
    assert_eq!(monarch.current_monarch, Some(players[0]));
}

#[test]
fn test_monarch_when_player_leaves_game() {
    let mut app = App::new();
    app.add_plugins(MinimalPlugins)
       .add_plugin(PoliticsPlugin);
       
    // Setup multi-player game
    let players = setup_four_player_game(&mut app);
    
    // Make player 1 the monarch
    app.world.send_event(AssignMonarchEvent { player: players[0] });
    app.update();
    
    // Player leaves game
    app.world.send_event(PlayerLeftGameEvent { player: players[0] });
    app.update();
    
    // Verify monarch is reset
    let monarch = app.world.resource::<MonarchState>();
    assert_eq!(monarch.current_monarch, None);
    
    // Verify next player can become monarch
    app.world.send_event(AssignMonarchEvent { player: players[1] });
    app.update();
    
    let monarch = app.world.resource::<MonarchState>();
    assert_eq!(monarch.current_monarch, Some(players[1]));
}
}

Multiple Monarch-Granting Effect Tests

Testing what happens when multiple effects attempt to assign the monarch:

#![allow(unused)]
fn main() {
#[test]
fn test_simultaneous_monarch_granting_effects() {
    let mut app = App::new();
    app.add_plugins(MinimalPlugins)
       .add_plugin(PoliticsPlugin);
       
    // Setup players
    let player1 = app.world.spawn(Player::default()).id();
    let player2 = app.world.spawn(Player::default()).id();
    
    // Create stack of effects
    let stack = app.world.resource_mut::<Stack>();
    
    // Add monarch-granting effects to stack
    stack.push(StackItem {
        effect_type: EffectType::GrantMonarch,
        source_player: player1,
        target_player: Some(player1),
        ..Default::default()
    });
    
    stack.push(StackItem {
        effect_type: EffectType::GrantMonarch,
        source_player: player2,
        target_player: Some(player2),
        ..Default::default()
    });
    
    // Resolve stack
    resolve_stack(&mut app);
    
    // Verify last effect resolved made player2 the monarch (LIFO order)
    let monarch = app.world.resource::<MonarchState>();
    assert_eq!(monarch.current_monarch, Some(player2));
}
}

Integration Tests

Testing monarch interactions with other systems:

#![allow(unused)]
fn main() {
#[test]
fn test_monarch_integration_with_replacement_effects() {
    let mut app = App::new();
    app.add_plugins(MinimalPlugins)
       .add_plugin(PoliticsPlugin)
       .add_plugin(ReplacementEffectsPlugin);
       
    // Setup test
    let player = app.world.spawn(Player::default()).id();
    
    // Create a replacement effect that modifies card draw
    let replacement = app.world.spawn((
        ReplacementEffect {
            effect_type: EffectType::DrawCard,
            replacement: ReplacementType::DrawExtraCard,
            controller: player,
        },
    )).id();
    
    // Make player the monarch
    app.world.send_event(AssignMonarchEvent { player });
    app.update();
    
    // Simulate end step
    let initial_hand_size = get_player_hand_size(&app, player);
    
    app.world.resource_mut::<TurnState>().current_player = player;
    app.world.resource_mut::<TurnState>().current_phase = Phase::End;
    app.world.send_event(PhaseEndEvent { phase: Phase::End });
    app.update();
    
    // Verify player drew 2 cards (1 from monarch + 1 from replacement)
    let new_hand_size = get_player_hand_size(&app, player);
    assert_eq!(new_hand_size, initial_hand_size + 2);
}
}

UI Testing

Testing the monarch UI representation:

#![allow(unused)]
fn main() {
#[test]
fn test_monarch_ui_representation() {
    let mut app = App::new();
    app.add_plugins(MinimalPlugins)
       .add_plugin(PoliticsPlugin)
       .add_plugin(UiPlugin);
       
    // Setup player
    let player = app.world.spawn(Player::default()).id();
    
    // Make player the monarch
    app.world.send_event(AssignMonarchEvent { player });
    app.update();
    
    // Query for monarch UI elements
    let monarch_indicators = app.world.query_filtered::<Entity, With<MonarchIndicator>>()
        .iter(&app.world)
        .collect::<Vec<_>>();
        
    // Verify monarch indicator exists
    assert!(!monarch_indicators.is_empty());
    
    // Verify monarch indicator is attached to player
    let indicator = monarch_indicators[0];
    let parent = app.world.get::<Parent>(indicator).unwrap();
    
    // Find player entity in UI hierarchy
    let player_ui = find_player_ui_entity(&app, player);
    assert_eq!(parent.get(), player_ui);
}
}

Conclusion

The Monarch testing suite ensures that this important political mechanic functions correctly in all scenarios. These tests verify that the Monarch designation is properly assigned, transferred, and provides its card advantage benefit consistently.

Voting System Testing

Overview

This document outlines the comprehensive testing approach for the voting system in Rummage's Commander format implementation. The voting mechanic appears on various cards like Council's Judgment, Expropriate, and Capital Punishment, and represents a key political interaction point in multiplayer games.

Vote Initialization Tests

Tests for the initialization of voting events:

#![allow(unused)]
fn main() {
#[test]
fn test_vote_initialization() {
    let mut app = App::new();
    app.add_plugins(MinimalPlugins)
       .add_plugin(PoliticsPlugin);
       
    // Setup players
    let players = setup_four_player_game(&mut app);
    
    // Create vote options
    let option_a = "Exile target permanent";
    let option_b = "Each player sacrifices a creature";
    
    // Initialize vote
    let vote_id = app.world.send_event(InitializeVoteEvent {
        initiator: players[0],
        options: vec![option_a.to_string(), option_b.to_string()],
        timing_restriction: VoteTiming::OnResolve,
    });
    app.update();
    
    // Verify vote was created
    let vote_state = app.world.resource::<VoteRegistry>().get_vote(vote_id).unwrap();
    assert_eq!(vote_state.options.len(), 2);
    assert_eq!(vote_state.initiator, players[0]);
    assert_eq!(vote_state.status, VoteStatus::Active);
    assert_eq!(vote_state.eligible_voters.len(), 4);
}

#[test]
fn test_vote_with_timing_restrictions() {
    let mut app = App::new();
    app.add_plugins(MinimalPlugins)
       .add_plugin(PoliticsPlugin)
       .add_plugin(TurnStructurePlugin);
       
    // Setup players
    let players = setup_four_player_game(&mut app);
    
    // Set current player and phase
    app.world.resource_mut::<TurnState>().current_player = players[0];
    app.world.resource_mut::<TurnState>().current_phase = Phase::Main1;
    
    // Initialize vote with sorcery timing
    let vote_id = app.world.send_event(InitializeVoteEvent {
        initiator: players[0],
        options: vec!["Option A".to_string(), "Option B".to_string()],
        timing_restriction: VoteTiming::SorcerySpeed,
    });
    app.update();
    
    // Verify vote is allowed during main phase of initiator's turn
    let vote_state = app.world.resource::<VoteRegistry>().get_vote(vote_id).unwrap();
    assert_eq!(vote_state.status, VoteStatus::Active);
    
    // Change to different player's turn
    app.world.resource_mut::<TurnState>().current_player = players[1];
    
    // Try to initialize another vote with sorcery timing
    let vote_id2 = app.world.send_event(InitializeVoteEvent {
        initiator: players[1],
        options: vec!["Option X".to_string(), "Option Y".to_string()],
        timing_restriction: VoteTiming::SorcerySpeed,
    });
    app.update();
    
    // Verify second vote is active (as it's now player1's turn)
    let vote_state2 = app.world.resource::<VoteRegistry>().get_vote(vote_id2).unwrap();
    assert_eq!(vote_state2.status, VoteStatus::Active);
    
    // Change to non-main phase
    app.world.resource_mut::<TurnState>().current_phase = Phase::Combat;
    
    // Try to initialize another vote with sorcery timing
    let vote_id3 = app.world.send_event(InitializeVoteEvent {
        initiator: players[1],
        options: vec!["Option C".to_string(), "Option D".to_string()],
        timing_restriction: VoteTiming::SorcerySpeed,
    });
    app.update();
    
    // Verify third vote is rejected due to timing
    let vote_state3 = app.world.resource::<VoteRegistry>().get_vote(vote_id3).unwrap();
    assert_eq!(vote_state3.status, VoteStatus::Rejected);
    assert_eq!(vote_state3.rejection_reason, Some(VoteRejectionReason::InvalidTiming));
}
}

Vote Casting Tests

Tests for the vote casting mechanics:

#![allow(unused)]
fn main() {
#[test]
fn test_basic_vote_casting() {
    let mut app = App::new();
    app.add_plugins(MinimalPlugins)
       .add_plugin(PoliticsPlugin);
       
    // Setup players
    let players = setup_four_player_game(&mut app);
    
    // Initialize vote
    let vote_id = app.world.send_event(InitializeVoteEvent {
        initiator: players[0],
        options: vec!["Option A".to_string(), "Option B".to_string()],
        timing_restriction: VoteTiming::OnResolve,
    });
    app.update();
    
    // Players cast votes
    app.world.send_event(CastVoteEvent {
        voter: players[0],
        vote_id,
        option_index: 0, // Option A
    });
    
    app.world.send_event(CastVoteEvent {
        voter: players[1],
        vote_id,
        option_index: 0, // Option A
    });
    
    app.world.send_event(CastVoteEvent {
        voter: players[2],
        vote_id,
        option_index: 1, // Option B
    });
    
    app.world.send_event(CastVoteEvent {
        voter: players[3],
        vote_id,
        option_index: 1, // Option B
    });
    
    app.update();
    
    // Verify votes were recorded
    let vote_state = app.world.resource::<VoteRegistry>().get_vote(vote_id).unwrap();
    assert_eq!(vote_state.votes.len(), 4);
    assert_eq!(vote_state.vote_counts[0], 2); // Option A has 2 votes
    assert_eq!(vote_state.vote_counts[1], 2); // Option B has 2 votes
}

#[test]
fn test_vote_weight_modifiers() {
    let mut app = App::new();
    app.add_plugins(MinimalPlugins)
       .add_plugin(PoliticsPlugin);
       
    // Setup players
    let players = setup_four_player_game(&mut app);
    
    // Add a vote weight modifier to player0
    app.world.entity_mut(players[0]).insert(VoteWeightModifier {
        multiplier: 2.0,
        expiration: VoteWeightExpiration::Permanent,
    });
    
    // Initialize vote
    let vote_id = app.world.send_event(InitializeVoteEvent {
        initiator: players[0],
        options: vec!["Option A".to_string(), "Option B".to_string()],
        timing_restriction: VoteTiming::OnResolve,
    });
    app.update();
    
    // Players cast votes
    app.world.send_event(CastVoteEvent {
        voter: players[0],
        vote_id,
        option_index: 0, // Option A - should count as 2 votes
    });
    
    app.world.send_event(CastVoteEvent {
        voter: players[1],
        vote_id,
        option_index: 1, // Option B
    });
    
    app.world.send_event(CastVoteEvent {
        voter: players[2],
        vote_id,
        option_index: 1, // Option B
    });
    
    app.update();
    
    // Verify vote weights were applied correctly
    let vote_state = app.world.resource::<VoteRegistry>().get_vote(vote_id).unwrap();
    assert_eq!(vote_state.vote_counts[0], 2); // Option A has 2 votes (from weighted player)
    assert_eq!(vote_state.vote_counts[1], 2); // Option B has 2 votes (1 each from two players)
}

#[test]
fn test_vote_time_limits() {
    let mut app = App::new();
    app.add_plugins(MinimalPlugins)
       .add_plugin(PoliticsPlugin)
       .add_plugin(TimePlugin);
       
    // Setup players
    let players = setup_four_player_game(&mut app);
    
    // Initialize vote with time limit
    let vote_id = app.world.send_event(InitializeVoteEvent {
        initiator: players[0],
        options: vec!["Option A".to_string(), "Option B".to_string()],
        timing_restriction: VoteTiming::OnResolve,
        time_limit: Some(std::time::Duration::from_secs(30)),
    });
    app.update();
    
    // Players cast some votes
    app.world.send_event(CastVoteEvent {
        voter: players[0],
        vote_id,
        option_index: 0,
    });
    
    app.world.send_event(CastVoteEvent {
        voter: players[1],
        vote_id,
        option_index: 1,
    });
    
    app.update();
    
    // Fast forward time past the limit
    advance_time(&mut app, std::time::Duration::from_secs(31));
    app.update();
    
    // Verify vote was automatically closed
    let vote_state = app.world.resource::<VoteRegistry>().get_vote(vote_id).unwrap();
    assert_eq!(vote_state.status, VoteStatus::Closed);
    
    // Try to cast another vote
    app.world.send_event(CastVoteEvent {
        voter: players[2],
        vote_id,
        option_index: 0,
    });
    app.update();
    
    // Verify late vote was not counted
    let vote_state = app.world.resource::<VoteRegistry>().get_vote(vote_id).unwrap();
    assert_eq!(vote_state.votes.len(), 2); // Still only 2 votes
}
}

Vote Resolution Tests

Tests for resolving votes and applying their effects:

#![allow(unused)]
fn main() {
#[test]
fn test_vote_resolution_with_clear_winner() {
    let mut app = App::new();
    app.add_plugins(MinimalPlugins)
       .add_plugin(PoliticsPlugin);
       
    // Setup players
    let players = setup_four_player_game(&mut app);
    
    // Initialize vote
    let vote_id = app.world.send_event(InitializeVoteEvent {
        initiator: players[0],
        options: vec!["Option A".to_string(), "Option B".to_string()],
        timing_restriction: VoteTiming::OnResolve,
    });
    app.update();
    
    // Players cast votes with clear winner
    app.world.send_event(CastVoteEvent {
        voter: players[0],
        vote_id,
        option_index: 0,
    });
    
    app.world.send_event(CastVoteEvent {
        voter: players[1],
        vote_id,
        option_index: 0,
    });
    
    app.world.send_event(CastVoteEvent {
        voter: players[2],
        vote_id,
        option_index: 0,
    });
    
    app.world.send_event(CastVoteEvent {
        voter: players[3],
        vote_id,
        option_index: 1,
    });
    
    app.update();
    
    // Resolve vote
    app.world.send_event(ResolveVoteEvent { vote_id });
    app.update();
    
    // Verify correct option won
    let vote_state = app.world.resource::<VoteRegistry>().get_vote(vote_id).unwrap();
    assert_eq!(vote_state.status, VoteStatus::Resolved);
    assert_eq!(vote_state.winning_option, Some(0)); // Option A won
}

#[test]
fn test_vote_tie_breaking() {
    let mut app = App::new();
    app.add_plugins(MinimalPlugins)
       .add_plugin(PoliticsPlugin);
       
    // Setup players
    let players = setup_four_player_game(&mut app);
    
    // Initialize vote with tie-breaker
    let vote_id = app.world.send_event(InitializeVoteEvent {
        initiator: players[0],
        options: vec!["Option A".to_string(), "Option B".to_string()],
        timing_restriction: VoteTiming::OnResolve,
        tie_breaker: VoteTieBreaker::Initiator,
    });
    app.update();
    
    // Players cast votes resulting in tie
    app.world.send_event(CastVoteEvent {
        voter: players[0],
        vote_id,
        option_index: 0, // Initiator votes for Option A
    });
    
    app.world.send_event(CastVoteEvent {
        voter: players[1],
        vote_id,
        option_index: 0,
    });
    
    app.world.send_event(CastVoteEvent {
        voter: players[2],
        vote_id,
        option_index: 1,
    });
    
    app.world.send_event(CastVoteEvent {
        voter: players[3],
        vote_id,
        option_index: 1,
    });
    
    app.update();
    
    // Resolve vote
    app.world.send_event(ResolveVoteEvent { vote_id });
    app.update();
    
    // Verify initiator's choice won the tie
    let vote_state = app.world.resource::<VoteRegistry>().get_vote(vote_id).unwrap();
    assert_eq!(vote_state.status, VoteStatus::Resolved);
    assert_eq!(vote_state.winning_option, Some(0)); // Option A won due to initiator tie-break
    
    // Test different tie-breaker: Random
    let vote_id2 = app.world.send_event(InitializeVoteEvent {
        initiator: players[0],
        options: vec!["Option X".to_string(), "Option Y".to_string()],
        timing_restriction: VoteTiming::OnResolve,
        tie_breaker: VoteTieBreaker::Random,
    });
    app.update();
    
    // Players cast votes resulting in tie
    for player in &players {
        app.world.send_event(CastVoteEvent {
            voter: *player,
            vote_id: vote_id2,
            option_index: player.index() % 2, // Evenly split votes
        });
    }
    app.update();
    
    // Set up deterministic RNG for testing
    app.insert_resource(TestRng::with_seed(12345));
    
    // Resolve vote
    app.world.send_event(ResolveVoteEvent { vote_id: vote_id2 });
    app.update();
    
    // Verify a random winner was selected
    let vote_state = app.world.resource::<VoteRegistry>().get_vote(vote_id2).unwrap();
    assert_eq!(vote_state.status, VoteStatus::Resolved);
    assert!(vote_state.winning_option.is_some()); // Some option was chosen randomly
}

#[test]
fn test_vote_effect_application() {
    let mut app = App::new();
    app.add_plugins(MinimalPlugins)
       .add_plugin(PoliticsPlugin);
       
    // Setup players
    let players = setup_four_player_game(&mut app);
    
    // Create test permanent to potentially exile
    let target_permanent = app.world.spawn((
        Permanent { controller: players[1], ..Default::default() },
    )).id();
    
    // Create vote with exile effect
    let vote_id = app.world.send_event(InitializeVoteEvent {
        initiator: players[0],
        options: vec!["Exile permanent".to_string(), "Draw cards".to_string()],
        timing_restriction: VoteTiming::OnResolve,
        effects: vec![
            VoteEffect::ExilePermanent { target: target_permanent },
            VoteEffect::DrawCards { player: players[0], count: 2 },
        ],
    });
    app.update();
    
    // Players vote to exile the permanent
    for player in &players {
        app.world.send_event(CastVoteEvent {
            voter: *player,
            vote_id,
            option_index: 0, // Everyone votes to exile
        });
    }
    app.update();
    
    // Resolve vote
    app.world.send_event(ResolveVoteEvent { vote_id });
    app.update();
    
    // Verify permanent was exiled
    let zone = app.world.get::<Zone>(target_permanent).unwrap();
    assert_eq!(*zone, Zone::Exile);
}
}

Edge Case Tests

Testing unusual voting scenarios:

#![allow(unused)]
fn main() {
#[test]
fn test_vote_abstention() {
    let mut app = App::new();
    app.add_plugins(MinimalPlugins)
       .add_plugin(PoliticsPlugin);
       
    // Setup players
    let players = setup_four_player_game(&mut app);
    
    // Initialize vote
    let vote_id = app.world.send_event(InitializeVoteEvent {
        initiator: players[0],
        options: vec!["Option A".to_string(), "Option B".to_string()],
        timing_restriction: VoteTiming::OnResolve,
        allow_abstention: true,
    });
    app.update();
    
    // Two players vote, two abstain
    app.world.send_event(CastVoteEvent {
        voter: players[0],
        vote_id,
        option_index: 0,
    });
    
    app.world.send_event(CastVoteEvent {
        voter: players[1],
        vote_id,
        option_index: 1,
    });
    
    // Players 2 and 3 abstain by not voting
    
    // Close vote manually
    app.world.send_event(ResolveVoteEvent { vote_id });
    app.update();
    
    // Verify vote was counted correctly with abstentions
    let vote_state = app.world.resource::<VoteRegistry>().get_vote(vote_id).unwrap();
    assert_eq!(vote_state.votes.len(), 2); // Only 2 votes cast
    assert_eq!(vote_state.eligible_voters.len(), 4); // But 4 eligible voters
    assert_eq!(vote_state.abstention_count, 2); // 2 abstentions
}

#[test]
fn test_vote_with_modifiers() {
    let mut app = App::new();
    app.add_plugins(MinimalPlugins)
       .add_plugin(PoliticsPlugin);
       
    // Setup players
    let players = setup_four_player_game(&mut app);
    
    // Create card that affects votes
    let vote_modifier_card = app.world.spawn((
        Card::default(),
        VoteModifier {
            effect_type: VoteModifierType::DoubleVotesForOption { option_index: 0 },
            controller: players[0],
        },
    )).id();
    
    // Initialize vote
    let vote_id = app.world.send_event(InitializeVoteEvent {
        initiator: players[0],
        options: vec!["Option A".to_string(), "Option B".to_string()],
        timing_restriction: VoteTiming::OnResolve,
    });
    app.update();
    
    // Players cast votes
    app.world.send_event(CastVoteEvent {
        voter: players[0],
        vote_id,
        option_index: 0, // This vote should be doubled
    });
    
    app.world.send_event(CastVoteEvent {
        voter: players[1],
        vote_id,
        option_index: 0,
    });
    
    app.world.send_event(CastVoteEvent {
        voter: players[2],
        vote_id,
        option_index: 1,
    });
    
    app.world.send_event(CastVoteEvent {
        voter: players[3],
        vote_id,
        option_index: 1,
    });
    
    app.update();
    
    // Resolve vote
    app.world.send_event(ResolveVoteEvent { vote_id });
    app.update();
    
    // Verify vote modifier was applied
    let vote_state = app.world.resource::<VoteRegistry>().get_vote(vote_id).unwrap();
    assert_eq!(vote_state.modified_vote_counts[0], 3); // 2 votes, but player0's vote doubled
    assert_eq!(vote_state.modified_vote_counts[1], 2); // 2 normal votes
    assert_eq!(vote_state.winning_option, Some(0));
}
}

UI and Network Tests

Testing the voting UI and network synchronization:

#![allow(unused)]
fn main() {
#[test]
fn test_vote_ui_representation() {
    let mut app = App::new();
    app.add_plugins(MinimalPlugins)
       .add_plugin(PoliticsPlugin)
       .add_plugin(UiPlugin);
       
    // Setup players
    let players = setup_four_player_game(&mut app);
    
    // Initialize vote
    let vote_id = app.world.send_event(InitializeVoteEvent {
        initiator: players[0],
        options: vec!["Option A".to_string(), "Option B".to_string()],
        timing_restriction: VoteTiming::OnResolve,
    });
    app.update();
    
    // Verify vote UI elements were created
    let vote_ui_elements = app.world.query_filtered::<Entity, With<VoteUiElement>>()
        .iter(&app.world)
        .collect::<Vec<_>>();
    
    assert!(!vote_ui_elements.is_empty());
    
    // Verify options are displayed
    let vote_options = app.world.query_filtered::<&Text, With<VoteOptionText>>()
        .iter(&app.world)
        .collect::<Vec<_>>();
    
    assert_eq!(vote_options.len(), 2);
    assert!(vote_options[0].sections[0].value.contains("Option A"));
    assert!(vote_options[1].sections[0].value.contains("Option B"));
}

#[test]
fn test_vote_network_synchronization() {
    let mut app = TestNetworkApp::new();
    app.add_plugins(MinimalPlugins)
       .add_plugin(PoliticsPlugin)
       .add_plugin(NetworkPlugin);
       
    // Setup networked game with host and client
    let (host_id, client_id) = app.setup_network_game(2);
    
    // Host initializes vote
    app.send_host_message(InitializeVoteMessage {
        initiator: host_id,
        options: vec!["Option A".to_string(), "Option B".to_string()],
        vote_id: VoteId::new(),
    });
    app.update();
    
    // Verify vote was synchronized to client
    let client_votes = app.get_client_resource::<VoteRegistry>().active_votes();
    assert_eq!(client_votes.len(), 1);
    
    // Client casts vote
    app.send_client_message(CastVoteMessage {
        voter: client_id,
        vote_id: client_votes[0].id,
        option_index: 1,
    });
    app.update();
    
    // Verify vote was recorded on host
    let host_vote = app.get_host_resource::<VoteRegistry>().get_vote(client_votes[0].id).unwrap();
    assert_eq!(host_vote.votes.len(), 1);
    assert_eq!(host_vote.votes[0].voter, client_id);
    assert_eq!(host_vote.votes[0].option_index, 1);
}
}

Conclusion

The voting system is a complex but essential political mechanic in Commander format. These tests ensure that votes are properly initialized, cast, tallied, and resolved under all circumstances, ensuring a fair and predictable voting experience for players.

Special Rules Tests

This section contains tests related to the special rules and mechanics unique to the Commander format.

Tests

Commander-Specific Rules Tests

Tests for the implementation of Commander-specific rules, including:

  • Color identity restrictions
  • Commander damage tracking
  • Command zone mechanics
  • Commander tax
  • Commander replacement effects
  • Partner and Background mechanics
  • Commander ninjutsu
  • Commander death triggers

These tests ensure that the unique rules of Commander are properly implemented and function correctly within the game engine.

Commander-Specific Rules Tests

Overview

This document outlines test cases for Commander-specific rules, including starting life totals, commander tax, color identity restrictions, singleton deck construction, and other format-specific mechanics that don't fit into other test categories.

Test Case: Starting Life Total

Test: 40 Life for Commander Format

#![allow(unused)]
fn main() {
#[test]
fn test_commander_starting_life() {
    // Test setup
    let mut app = App::new();
    app.add_plugins(MinimalPlugins)
       .add_systems(Update, initialize_game);
       
    // Set game format
    app.insert_resource(GameFormat::Commander);
    
    // Create players
    let player1 = app.world.spawn(Player {}).id();
    let player2 = app.world.spawn(Player {}).id();
    let player3 = app.world.spawn(Player {}).id();
    let player4 = app.world.spawn(Player {}).id();
    
    // Initialize game
    app.world.send_event(InitializeGameEvent {
        players: vec![player1, player2, player3, player4],
    });
    app.update();
    
    // Verify all players have 40 life
    for player in [player1, player2, player3, player4].iter() {
        let health = app.world.get::<Health>(*player).unwrap();
        assert_eq!(health.current, 40);
        assert_eq!(health.maximum, 40);
    }
}
}

Test Case: 100-Card Singleton Deck Validation

Test: Singleton Rule (Only One Copy of Each Card)

#![allow(unused)]
fn main() {
#[test]
fn test_singleton_rule() {
    // Test setup
    let mut app = App::new();
    app.add_plugins(MinimalPlugins)
       .add_systems(Update, validate_commander_deck);
       
    // Create player
    let player = app.world.spawn(Player {}).id();
    
    // Create a commander
    let commander = app.world.spawn((
        Card { name: "Commander".to_string() },
        Commander { owner: player, cast_count: 0 },
    )).id();
    
    // Create cards for the deck
    let card1 = app.world.spawn((
        Card { name: "Unique Card 1".to_string() },
        CardIdentity { oracle_id: "id1".to_string() },
    )).id();
    
    let card2 = app.world.spawn((
        Card { name: "Unique Card 2".to_string() },
        CardIdentity { oracle_id: "id2".to_string() },
    )).id();
    
    // Duplicate card (same oracle ID)
    let duplicate_card = app.world.spawn((
        Card { name: "Unique Card 1".to_string() },
        CardIdentity { oracle_id: "id1".to_string() },
    )).id();
    
    // Basic land (allowed multiple copies)
    let basic_land = app.world.spawn((
        Card { name: "Forest".to_string() },
        CardIdentity { oracle_id: "forest_id".to_string() },
        BasicLand,
    )).id();
    
    let basic_land2 = app.world.spawn((
        Card { name: "Forest".to_string() },
        CardIdentity { oracle_id: "forest_id".to_string() },
        BasicLand,
    )).id();
    
    // Create deck with the commander
    app.world.spawn((
        Deck { owner: player },
        CommanderDeck { commander },
        Cards { entities: vec![card1, card2, duplicate_card, basic_land, basic_land2] },
    ));
    
    // Validate deck
    app.update();
    
    // Verify validation errors for duplicate card
    let validation_errors = app.world.resource::<ValidationErrors>();
    assert!(!validation_errors.is_empty());
    
    // Should have error about duplicate card
    let duplicate_error = validation_errors.errors.iter().any(|error| {
        error.card == duplicate_card && error.error_type == ValidationErrorType::DuplicateCard
    });
    assert!(duplicate_error);
    
    // Should NOT have error about basic lands
    let basic_land_error = validation_errors.errors.iter().any(|error| {
        error.card == basic_land || error.card == basic_land2
    });
    assert!(!basic_land_error);
}
}

Test: Exactly 100 Cards Including Commander

#![allow(unused)]
fn main() {
#[test]
fn test_deck_size() {
    // Test setup
    let mut app = App::new();
    app.add_plugins(MinimalPlugins)
       .add_systems(Update, validate_commander_deck);
       
    // Create player
    let player = app.world.spawn(Player {}).id();
    
    // Create a commander
    let commander = app.world.spawn((
        Card { name: "Commander".to_string() },
        Commander { owner: player, cast_count: 0 },
    )).id();
    
    // Create 98 cards for deck (which is invalid, should be 99 + commander)
    let mut cards = Vec::new();
    for i in 0..98 {
        let card = app.world.spawn((
            Card { name: format!("Card {}", i) },
            CardIdentity { oracle_id: format!("id{}", i) },
        )).id();
        cards.push(card);
    }
    
    // Create deck with the commander
    app.world.spawn((
        Deck { owner: player },
        CommanderDeck { commander },
        Cards { entities: cards.clone() },
    ));
    
    // Validate deck
    app.update();
    
    // Verify validation error for deck size
    let validation_errors = app.world.resource::<ValidationErrors>();
    assert!(!validation_errors.is_empty());
    
    let size_error = validation_errors.errors.iter().any(|error| {
        error.error_type == ValidationErrorType::InvalidDeckSize
    });
    assert!(size_error);
    
    // Add one more card to make it 99 + commander = 100
    let last_card = app.world.spawn((
        Card { name: "Card 99".to_string() },
        CardIdentity { oracle_id: "id99".to_string() },
    )).id();
    cards.push(last_card);
    
    // Update deck
    app.world.query_mut::<&mut Cards>().for_each_mut(|mut deck_cards| {
        deck_cards.entities = cards.clone();
    });
    
    // Clear previous errors
    app.world.resource_mut::<ValidationErrors>().errors.clear();
    
    // Re-validate deck
    app.update();
    
    // Verify no validation errors for deck size
    let validation_errors = app.world.resource::<ValidationErrors>();
    let size_error = validation_errors.errors.iter().any(|error| {
        error.error_type == ValidationErrorType::InvalidDeckSize
    });
    assert!(!size_error);
}
}

Test Case: Commander Tax Implementation

Test: Tracking Commander Tax Across Zone Changes

#![allow(unused)]
fn main() {
#[test]
fn test_commander_tax_tracking() {
    // Test setup
    let mut app = App::new();
    app.add_plugins(MinimalPlugins)
       .add_systems(Update, (update_commander_tax, handle_zone_transitions, cast_from_command_zone));
       
    // Create player
    let player = app.world.spawn((
        Player {},
        Mana::default(),
    )).id();
    
    // Create commander
    let commander = app.world.spawn((
        Card { 
            name: "Test Commander".to_string(),
            cost: ManaCost::from_string("{3}{R}").unwrap(),
        },
        Commander { owner: player, cast_count: 0 },
        Zone::CommandZone,
    )).id();
    
    // Cast commander first time
    app.world.send_event(CastSpellEvent {
        caster: player,
        spell: commander,
    });
    app.update();
    
    // Verify commander moved to stack
    assert_eq!(app.world.get::<Zone>(commander).unwrap(), &Zone::Stack);
    
    // Verify cast count increased to 1
    assert_eq!(app.world.get::<Commander>(commander).unwrap().cast_count, 1);
    
    // Resolve spell (move to battlefield)
    app.world.send_event(ZoneChangeEvent {
        entity: commander,
        from: Zone::Stack,
        to: Zone::Battlefield,
        cause: ZoneChangeCause::SpellResolution,
    });
    app.update();
    
    // Send to command zone
    app.world.send_event(ZoneChangeEvent {
        entity: commander,
        from: Zone::Battlefield,
        to: Zone::CommandZone,
        cause: ZoneChangeCause::CommanderReplacement,
    });
    app.update();
    
    // Cast commander second time
    app.world.send_event(CastSpellEvent {
        caster: player,
        spell: commander,
    });
    app.update();
    
    // Verify cast count increased to 2
    assert_eq!(app.world.get::<Commander>(commander).unwrap().cast_count, 2);
    
    // Verify cost now includes commander tax (2 additional mana)
    let required_mana = app.world.resource::<LastCastAttempt>().required_mana.clone();
    assert_eq!(required_mana.total_cmc(), 7); // 3R + 2 tax = 7
}
}

Test Case: Partner Commanders

Test: Two Partner Commanders

#![allow(unused)]
fn main() {
#[test]
fn test_partner_commanders() {
    // Test setup
    let mut app = App::new();
    app.add_plugins(MinimalPlugins)
       .add_systems(Update, validate_commander_setup);
       
    // Create player
    let player = app.world.spawn(Player {}).id();
    
    // Create two partner commanders
    let commander1 = app.world.spawn((
        Card { name: "Partner Commander 1".to_string() },
        Commander { owner: player, cast_count: 0 },
        PartnerAbility,
        ColorIdentity { colors: vec![Color::Red, Color::White] },
    )).id();
    
    let commander2 = app.world.spawn((
        Card { name: "Partner Commander 2".to_string() },
        Commander { owner: player, cast_count: 0 },
        PartnerAbility,
        ColorIdentity { colors: vec![Color::Blue, Color::Black] },
    )).id();
    
    // Set commanders for player
    app.world.spawn((
        CommanderList { commanders: vec![commander1, commander2] },
        Owner { player },
    ));
    
    // Validate commander setup
    app.update();
    
    // Verify no validation errors
    let validation_errors = app.world.resource::<ValidationErrors>();
    assert!(validation_errors.is_empty());
    
    // Verify combined color identity
    let color_identity = app.world.resource::<CombinedColorIdentity>();
    let player_identity = color_identity.get_colors(player);
    assert!(player_identity.contains(&Color::Red));
    assert!(player_identity.contains(&Color::White));
    assert!(player_identity.contains(&Color::Blue));
    assert!(player_identity.contains(&Color::Black));
}
}

Test: Two Non-Partner Commanders (Invalid)

#![allow(unused)]
fn main() {
#[test]
fn test_invalid_multiple_commanders() {
    // Test setup
    let mut app = App::new();
    app.add_plugins(MinimalPlugins)
       .add_systems(Update, validate_commander_setup);
       
    // Create player
    let player = app.world.spawn(Player {}).id();
    
    // Create two regular commanders without partner
    let commander1 = app.world.spawn((
        Card { name: "Commander 1".to_string() },
        Commander { owner: player, cast_count: 0 },
        ColorIdentity { colors: vec![Color::Green] },
    )).id();
    
    let commander2 = app.world.spawn((
        Card { name: "Commander 2".to_string() },
        Commander { owner: player, cast_count: 0 },
        ColorIdentity { colors: vec![Color::Black] },
    )).id();
    
    // Set commanders for player
    app.world.spawn((
        CommanderList { commanders: vec![commander1, commander2] },
        Owner { player },
    ));
    
    // Validate commander setup
    app.update();
    
    // Verify validation errors
    let validation_errors = app.world.resource::<ValidationErrors>();
    assert!(!validation_errors.is_empty());
    
    // Should have error about too many commanders without partner
    let multi_commander_error = validation_errors.errors.iter().any(|error| {
        error.error_type == ValidationErrorType::MultipleCommandersWithoutPartner
    });
    assert!(multi_commander_error);
}
}

Test Case: Commander Color Identity

Test: Combined Color Identity with Partner Commanders

#![allow(unused)]
fn main() {
#[test]
fn test_color_identity_calculation() {
    // Test setup
    let mut app = App::new();
    app.add_plugins(MinimalPlugins)
       .add_systems(Update, (calculate_color_identity, validate_commander_deck));
       
    // Create player
    let player = app.world.spawn(Player {}).id();
    
    // Create partner commanders with different color identities
    let commander1 = app.world.spawn((
        Card { name: "Silas Renn".to_string() },
        Commander { owner: player, cast_count: 0 },
        PartnerAbility,
        ColorIdentity { colors: vec![Color::Blue, Color::Black] },
    )).id();
    
    let commander2 = app.world.spawn((
        Card { name: "Akiri".to_string() },
        Commander { owner: player, cast_count: 0 },
        PartnerAbility,
        ColorIdentity { colors: vec![Color::Red, Color::White] },
    )).id();
    
    // Set commanders for player
    app.world.spawn((
        CommanderList { commanders: vec![commander1, commander2] },
        Owner { player },
    ));
    
    // Calculate combined color identity
    app.update();
    
    // Verify color identity includes all colors from both commanders
    let color_identity = app.world.resource::<CombinedColorIdentity>();
    let player_identity = color_identity.get_colors(player);
    
    assert!(player_identity.contains(&Color::Blue));
    assert!(player_identity.contains(&Color::Black));
    assert!(player_identity.contains(&Color::Red));
    assert!(player_identity.contains(&Color::White));
    assert!(!player_identity.contains(&Color::Green)); // Should not have green
    
    // Create cards with various color identities
    let valid_card = app.world.spawn((
        Card { name: "Esper Card".to_string() },
        ColorIdentity { colors: vec![Color::White, Color::Blue, Color::Black] },
    )).id();
    
    let invalid_card = app.world.spawn((
        Card { name: "Green Card".to_string() },
        ColorIdentity { colors: vec![Color::Green] },
    )).id();
    
    // Create deck with these cards
    app.world.spawn((
        Deck { owner: player },
        CommanderDeck { commander: commander1 }, // Just refers to one commander
        Cards { entities: vec![valid_card, invalid_card] },
    ));
    
    // Validate deck against color identity
    app.update();
    
    // Verify validation error for card outside color identity
    let validation_errors = app.world.resource::<ValidationErrors>();
    assert!(!validation_errors.is_empty());
    
    let color_identity_error = validation_errors.errors.iter().any(|error| {
        error.card == invalid_card && error.error_type == ValidationErrorType::ColorIdentityViolation
    });
    assert!(color_identity_error);
    
    // Should NOT have error for valid card
    let valid_card_error = validation_errors.errors.iter().any(|error| {
        error.card == valid_card
    });
    assert!(!valid_card_error);
}
}

Test: Color Identity Includes Mana Symbols in Rules Text

#![allow(unused)]
fn main() {
#[test]
fn test_color_identity_from_rules_text() {
    // Test setup
    let mut app = App::new();
    app.add_plugins(MinimalPlugins)
       .add_systems(Update, calculate_comprehensive_color_identity);
       
    // Create player
    let player = app.world.spawn(Player {}).id();
    
    // Create cards with various color identity components
    
    // Card with mana cost only
    let card1 = app.world.spawn((
        Card { 
            name: "Red Creature".to_string(),
            cost: ManaCost::from_string("{1}{R}").unwrap(),
        },
        RulesText { text: "This is a red creature.".to_string() },
    )).id();
    
    // Card with color indicator
    let card2 = app.world.spawn((
        Card { 
            name: "Blue Card".to_string(),
            cost: ManaCost::from_string("{1}").unwrap(),
        },
        ColorIndicator { color: Color::Blue },
    )).id();
    
    // Card with mana symbol in rules text
    let card3 = app.world.spawn((
        Card { 
            name: "Colorless Card".to_string(),
            cost: ManaCost::from_string("{1}").unwrap(),
        },
        RulesText { text: "{G}: This card gets +1/+1 until end of turn.".to_string() },
    )).id();
    
    // Card with hybrid mana in cost
    let card4 = app.world.spawn((
        Card { 
            name: "Hybrid Card".to_string(),
            cost: ManaCost::from_string("{1}{W/B}").unwrap(),
        },
    )).id();
    
    // Calculate comprehensive color identity
    app.update();
    
    // Verify correct color identities
    let c1 = app.world.get::<ColorIdentity>(card1).unwrap();
    assert_eq!(c1.colors.len(), 1);
    assert!(c1.colors.contains(&Color::Red));
    
    let c2 = app.world.get::<ColorIdentity>(card2).unwrap();
    assert_eq!(c2.colors.len(), 1);
    assert!(c2.colors.contains(&Color::Blue));
    
    let c3 = app.world.get::<ColorIdentity>(card3).unwrap();
    assert_eq!(c3.colors.len(), 1);
    assert!(c3.colors.contains(&Color::Green));
    
    let c4 = app.world.get::<ColorIdentity>(card4).unwrap();
    assert_eq!(c4.colors.len(), 2);
    assert!(c4.colors.contains(&Color::White));
    assert!(c4.colors.contains(&Color::Black));
}
}

Test Case: Commander Damage

Test: Commander Damage Win Condition

#![allow(unused)]
fn main() {
#[test]
fn test_commander_damage_win_condition() {
    // Test setup
    let mut app = App::new();
    app.add_plugins(MinimalPlugins)
       .add_systems(Update, (apply_combat_damage, check_commander_damage_win_condition));
       
    // Create players
    let player1 = app.world.spawn((
        Player { id: 1 },
        Health { current: 40, maximum: 40 },
        CommanderDamage::default(),
    )).id();
    
    let player2 = app.world.spawn((
        Player { id: 2 },
        Health { current: 40, maximum: 40 },
    )).id();
    
    // Create commander for player 2
    let commander = app.world.spawn((
        Card { name: "Commander".to_string() },
        Commander { owner: player2, cast_count: 0 },
        Creature { power: 7, toughness: 7 },
        Zone::Battlefield,
    )).id();
    
    // Apply commander damage 3 times (7 * 3 = 21 damage)
    for _ in 0..3 {
        app.world.send_event(CommanderDamageEvent {
            commander,
            target: player1,
            amount: 7,
        });
        app.update();
    }
    
    // Verify player received 21 commander damage
    let commander_damage = app.world.get::<CommanderDamage>(player1).unwrap();
    assert_eq!(commander_damage.get_damage(commander), 21);
    
    // Verify player was eliminated due to commander damage
    let player_status = app.world.get::<PlayerStatus>(player1).unwrap();
    assert_eq!(player_status.state, PlayerState::Eliminated);
    
    // Verify elimination reason is commander damage
    assert_eq!(player_status.elimination_reason, Some(EliminationReason::CommanderDamage));
}
}

Test: Tracking Commander Damage Separately

#![allow(unused)]
fn main() {
#[test]
fn test_multiple_commander_damage_tracking() {
    // Test setup
    let mut app = App::new();
    app.add_plugins(MinimalPlugins)
       .add_systems(Update, apply_combat_damage);
       
    // Create player and opponents
    let player = app.world.spawn((
        Player {},
        Health { current: 40, maximum: 40 },
        CommanderDamage::default(),
    )).id();
    
    let opponent1 = app.world.spawn(Player { id: 1 }).id();
    let opponent2 = app.world.spawn(Player { id: 2 }).id();
    let opponent3 = app.world.spawn(Player { id: 3 }).id();
    
    // Create commanders for each opponent
    let commander1 = app.world.spawn((
        Card { name: "Commander 1".to_string() },
        Commander { owner: opponent1, cast_count: 0 },
        Creature { power: 3, toughness: 3 },
    )).id();
    
    let commander2 = app.world.spawn((
        Card { name: "Commander 2".to_string() },
        Commander { owner: opponent2, cast_count: 0 },
        Creature { power: 5, toughness: 5 },
    )).id();
    
    let commander3 = app.world.spawn((
        Card { name: "Commander 3".to_string() },
        Commander { owner: opponent3, cast_count: 0 },
        Creature { power: 2, toughness: 2 },
    )).id();
    
    // Apply damage from each commander
    app.world.send_event(CommanderDamageEvent {
        commander: commander1,
        target: player,
        amount: 3,
    });
    
    app.world.send_event(CommanderDamageEvent {
        commander: commander2,
        target: player,
        amount: 5,
    });
    
    app.world.send_event(CommanderDamageEvent {
        commander: commander3,
        target: player,
        amount: 2,
    });
    app.update();
    
    // Verify damage was tracked separately for each commander
    let commander_damage = app.world.get::<CommanderDamage>(player).unwrap();
    assert_eq!(commander_damage.get_damage(commander1), 3);
    assert_eq!(commander_damage.get_damage(commander2), 5);
    assert_eq!(commander_damage.get_damage(commander3), 2);
    
    // Verify total life loss is the sum of all commander damage
    let health = app.world.get::<Health>(player).unwrap();
    assert_eq!(health.current, 30); // 40 - (3+5+2)
    
    // Apply more damage from commander2 to reach lethal from a single commander
    app.world.send_event(CommanderDamageEvent {
        commander: commander2,
        target: player,
        amount: 16,
    });
    app.update();
    
    // Verify commander2 damage is now 21
    let commander_damage = app.world.get::<CommanderDamage>(player).unwrap();
    assert_eq!(commander_damage.get_damage(commander2), 21);
    
    // Verify player was eliminated
    let player_status = app.world.get::<PlayerStatus>(player).unwrap();
    assert_eq!(player_status.state, PlayerState::Eliminated);
}
}

Test Case: Command Zone Mechanics

Test: Commander Replacement Effects for Different Zones

#![allow(unused)]
fn main() {
#[test]
fn test_commander_replacement_effects() {
    // Test setup
    let mut app = App::new();
    app.add_plugins(MinimalPlugins)
       .add_systems(Update, (handle_zone_transitions, commander_replacement_effects));
       
    // Create player with commander zone choice preferences
    let player = app.world.spawn((
        Player {},
        CommanderZoneChoice::default(), // Defaults to sending commanders to command zone
    )).id();
    
    // Create commander
    let commander = app.world.spawn((
        Card { name: "Commander".to_string() },
        Commander { owner: player, cast_count: 0 },
        Zone::Battlefield,
    )).id();
    
    // Test commander dying (would go to graveyard)
    app.world.send_event(ZoneChangeEvent {
        entity: commander,
        from: Zone::Battlefield,
        to: Zone::Graveyard,
        cause: ZoneChangeCause::Death,
    });
    app.update();
    
    // Verify commander went to command zone instead
    assert_eq!(app.world.get::<Zone>(commander).unwrap(), &Zone::CommandZone);
    
    // Move commander back to battlefield for next test
    app.world.get_mut::<Zone>(commander).unwrap().0 = Zone::Battlefield.0;
    
    // Test commander being exiled
    app.world.send_event(ZoneChangeEvent {
        entity: commander,
        from: Zone::Battlefield,
        to: Zone::Exile,
        cause: ZoneChangeCause::Exile,
    });
    app.update();
    
    // Verify commander went to command zone instead
    assert_eq!(app.world.get::<Zone>(commander).unwrap(), &Zone::CommandZone);
    
    // Test with player choosing to let commander go to graveyard
    app.world.get_mut::<CommanderZoneChoice>(player).unwrap().use_command_zone = false;
    
    // Move commander back to battlefield
    app.world.get_mut::<Zone>(commander).unwrap().0 = Zone::Battlefield.0;
    
    // Test commander dying with choice to go to graveyard
    app.world.send_event(ZoneChangeEvent {
        entity: commander,
        from: Zone::Battlefield,
        to: Zone::Graveyard,
        cause: ZoneChangeCause::Death,
    });
    app.update();
    
    // Verify commander went to graveyard as chosen
    assert_eq!(app.world.get::<Zone>(commander).unwrap(), &Zone::Graveyard);
}
}

These test cases provide comprehensive coverage for Commander-specific rules and mechanics that form the foundation of the format.

Commander Format: Core Rules Integration

This document explains how the Commander format extends and modifies the core Magic: The Gathering rules in Rummage.

Architecture Overview

Commander builds upon the format-agnostic core MTG rules through a layered architecture:

┌───────────────────────────────────────────┐
│             Commander Format              │
│  ┌─────────────┐  ┌────────────────────┐  │
│  │  Commander  │  │ Commander-Specific │  │
│  │  Components │  │      Systems       │  │
│  └─────────────┘  └────────────────────┘  │
├───────────────────────────────────────────┤
│            Extension Points               │
│  ┌─────────────┐  ┌────────────────────┐  │
│  │   Plugin    │  │     Override       │  │
│  │ Registration│  │    Mechanisms      │  │
│  └─────────────┘  └────────────────────┘  │
├───────────────────────────────────────────┤
│              Core MTG Rules               │
│  ┌─────────────┐  ┌────────────────────┐  │
│  │    Base     │  │       Base         │  │
│  │  Components │  │      Systems       │  │
│  └─────────────┘  └────────────────────┘  │
└───────────────────────────────────────────┘

This approach provides:

  • Clean Separation: Format-specific code doesn't contaminate core rules
  • Reusability: Core rules can support multiple formats
  • Testability: Format mechanics can be tested in isolation
  • Extensibility: New formats can be added without modifying core code

Core Extension Points

Commander leverages these primary extension mechanisms:

1. Zone Management Extensions

The core rules provide standard zones (library, hand, battlefield, etc.) which Commander extends with:

#![allow(unused)]
fn main() {
// Core system handles standard zones
fn core_zone_setup(mut commands: Commands) {
    // Create standard zones for each player
    // ...
}

// Commander plugin adds Command Zone support
fn commander_zone_setup(mut commands: Commands, players: Query<Entity, With<Player>>) {
    for player in &players {
        // Create Command Zone for this player
        commands.spawn((
            ZoneType::Command,
            ZoneContents::default(),
            BelongsToPlayer(player),
            CommandZone,
            Name::new("Command Zone"),
        ));
    }
}
}

2. Game Setup Extensions

Commander modifies initial game parameters:

#![allow(unused)]
fn main() {
// Commander plugin modifies core game setup
fn commander_game_setup(
    mut game_config: ResMut<GameConfig>,
    mut commands: Commands,
    players: Query<Entity, With<Player>>,
) {
    // Set Commander-specific configuration
    game_config.starting_life = 40;
    game_config.mulligan_rule = MulliganRule::CommanderFreeFirst;
    
    // Add Commander-specific components to players
    for player in &players {
        commands.entity(player).insert(CommanderDamageTracker::default());
    }
}
}

3. Rules System Extensions

Commander adds new rule checks and state-based actions:

#![allow(unused)]
fn main() {
// Core state-based action system
fn core_state_based_actions(/* ... */) {
    // Check creature death, player life, etc.
    // ...
}

// Commander adds additional state-based actions
fn commander_state_based_actions(
    players: Query<(Entity, &CommanderDamageTracker)>,
    mut game_events: EventWriter<GameEvent>,
) {
    // Check commander damage thresholds
    for (player, damage_tracker) in &players {
        for (&commander, &damage) in damage_tracker.damage_taken.iter() {
            if damage >= 21 {
                game_events.send(GameEvent::PlayerLost {
                    player,
                    reason: LossReason::CommanderDamage(commander),
                });
            }
        }
    }
}
}

Commander-Specific Overrides

In certain cases, Commander needs to override default core behavior:

Zone Transfer Overrides

When a commander would change zones, Commander rules allow for special handling:

#![allow(unused)]
fn main() {
// Commander zone transfer override
fn commander_zone_transfer_system(
    mut zone_events: EventReader<ZoneChangeEvent>,
    mut player_choices: EventWriter<PlayerChoiceEvent>,
    commanders: Query<Entity, With<Commander>>,
) {
    for event in zone_events.iter() {
        // If this is a commander...
        if commanders.contains(event.entity) {
            // And it's moving to graveyard/exile/hand/library...
            if matches!(event.to, 
                ZoneType::Graveyard | ZoneType::Exile | 
                ZoneType::Hand | ZoneType::Library
            ) {
                // Give the controller a choice
                player_choices.send(PlayerChoiceEvent {
                    player: get_controller(event.entity),
                    choice_type: ChoiceType::CommanderZoneChange {
                        commander: event.entity,
                        default_zone: event.to,
                        command_zone: ZoneType::Command,
                    },
                    timeout: Duration::from_secs(30),
                });
            }
        }
    }
}
}

Commander Tax Implementation

The Commander format adds tax to casting commanders from the Command Zone:

#![allow(unused)]
fn main() {
// Commander casting cost modification
fn apply_commander_tax(
    mut cast_events: EventReader<PrepareTocastEvent>,
    mut cast_costs: Query<&mut ManaCost>,
    commanders: Query<&Commander>,
    zones: Query<(&ZoneContents, &ZoneType)>,
) {
    for event in cast_events.iter() {
        // Check if this is a commander being cast
        if let Ok(commander) = commanders.get(event.card) {
            // Check if it's being cast from Command Zone
            if is_in_command_zone(event.card, &zones) {
                // Apply commander tax
                if let Ok(mut cost) = cast_costs.get_mut(event.card) {
                    let tax_amount = commander.cast_count * 2;
                    cost.add_generic(tax_amount);
                }
            }
        }
    }
}
}

Plugin Implementation

The Commander format is implemented as a Bevy plugin:

#![allow(unused)]
fn main() {
pub struct CommanderPlugin;

impl Plugin for CommanderPlugin {
    fn build(&self, app: &mut App) {
        // Register Commander-specific components
        app.register_type::<Commander>()
           .register_type::<CommanderDamageTracker>()
           .register_type::<CommanderTax>();
        
        // Add Commander-specific resources
        app.init_resource::<CommanderConfig>();
        
        // Add Commander setup systems
        app.add_systems(Startup, (
            commander_game_setup,
            commander_zone_setup,
        ));
        
        // Add Commander-specific gameplay systems
        app.add_systems(PreUpdate, (
            commander_zone_transfer_system
                .after(core_zone_change_system),
        ));
        
        // Add Commander rule enforcement systems
        app.add_systems(Update, (
            apply_commander_tax,
            track_commander_damage,
            commander_state_based_actions
                .after(core_state_based_actions),
        ));
        
        // Add Commander-specific events
        app.add_event::<CommanderCastEvent>()
           .add_event::<CommanderDamageEvent>();
    }
}
}

Example: Commander Zone Integration

The Command Zone is central to the Commander format. Here's its complete implementation:

#![allow(unused)]
fn main() {
// Commander-specific components
#[derive(Component, Reflect)]
pub struct Commander {
    pub owner: Entity,
    pub cast_count: u32,
}

#[derive(Component, Reflect)]
pub struct CommandZone;

// Command Zone setup
fn commander_zone_setup(
    mut commands: Commands, 
    players: Query<Entity, With<Player>>,
    mut decks: Query<(&mut DeckList, &BelongsToPlayer)>,
) {
    // Create Command Zone for each player
    for player in &players {
        // Create Command Zone entity
        let command_zone = commands.spawn((
            ZoneType::Command,
            ZoneContents::default(),
            BelongsToPlayer(player),
            CommandZone,
            Name::new("Command Zone"),
        )).id();
        
        // Find player's deck and locate commander
        if let Some((mut deck_list, _)) = decks
            .iter_mut()
            .find(|(_, belongs_to)| belongs_to.0 == player) 
        {
            // Extract commander(s) from deck
            if let Some(commander_card) = deck_list.extract_commander() {
                // Spawn commander entity
                let commander_entity = commands.spawn((
                    Card::from_definition(commander_card),
                    Commander { 
                        owner: player,
                        cast_count: 0,
                    },
                    InZone(ZoneType::Command),
                )).id();
                
                // Add commander to command zone
                commands.entity(command_zone)
                    .update_component(|mut contents: Mut<ZoneContents>| {
                        contents.entities.push(commander_entity);
                    });
            }
        }
    }
}
}

Testing Integration

Commander integration testing focuses on verifying that format-specific rules correctly interact with core systems:

#![allow(unused)]
fn main() {
#[test]
fn test_commander_zone_transfer() {
    // Setup test environment with both core and commander plugins
    let mut app = App::new();
    app.add_plugins(CoreRulesPlugin)
       .add_plugins(CommanderPlugin);
    
    // Setup test commander and zones
    let (player, commander) = setup_test_commander(&mut app);
    
    // Test that commander can move to command zone instead of graveyard
    app.world.send_event(ZoneChangeEvent {
        entity: commander,
        from: ZoneType::Battlefield,
        to: ZoneType::Graveyard,
    });
    
    // Handle player choice to move to command zone
    resolve_commander_zone_choice(&mut app, player, commander, ZoneType::Command);
    
    // Verify commander is in command zone
    let zone = get_entity_zone(&app, commander);
    assert_eq!(zone, ZoneType::Command, "Commander should be in Command Zone");
}
}

Conclusion

The Commander format implementation builds upon the core MTG rules through a clean plugin architecture that:

  1. Extends base functionality with Commander-specific features
  2. Overrides certain behaviors to match Commander rules
  3. Adds new components and systems unique to Commander

This approach maintains the integrity of the core rules while enabling the unique gameplay experience of the Commander format.

For detailed information on specific Commander mechanics, see:

Card Systems

This section documents the card systems of the Rummage MTG Commander game engine, covering the card database, deck database, effects implementation, rendering, and testing strategies.

Table of Contents

  1. Overview
  2. Key Components
  3. Implementation Status
  4. Integration with Game UI

Overview

The Card Systems module is the heart of Rummage's MTG implementation, responsible for representing cards, their attributes, and behaviors. This module handles everything from card data storage to deck management, effect resolution and visual representation.

In Rummage's ECS architecture, cards are entities with various components that define their properties, current state, and behaviors. These components include card type, mana cost, power/toughness for creatures, and other attributes defined in the MTG Comprehensive Rules. Systems then process these components to implement game mechanics such as casting spells, resolving abilities, and applying state-based actions.

Key Components

The Card Systems consist of the following major components:

  1. Card Database

    • Storage and retrieval of card data
    • Card attributes and properties
    • Oracle text processing
    • Card metadata and identification
  2. Deck Database

    • Deck creation and management
    • Format-specific validation
    • Deck persistence and sharing
    • Runtime deck operations
  3. Card Effects

    • Effect resolution system
    • Targeting mechanism
    • Complex card interactions
    • Ability parsing and implementation
  4. Card Rendering

    • Visual representation of cards
    • Card layout and templating
    • Art asset management
    • Dynamic card state visualization
  5. Testing Cards

    • Effect verification methodology
    • Interaction testing
    • Edge case coverage
    • Rules compliance verification

Implementation Status

ComponentStatusDescription
Core Card ModelBasic card data structure and properties
Card DatabaseStorage and retrieval of card information
Deck DatabaseDeck creation, storage, and manipulation
Format Validation🔄Deck validation for various formats
Basic EffectsSimple card effects (damage, draw, etc.)
Complex Effects🔄Advanced card effects and interactions
Targeting System🔄System for selecting targets for effects
Card RenderingVisual representation of cards
Effect Testing🔄Comprehensive testing of card effects
Card SymbolsRendering of mana symbols and other icons
Keywords🔄Implementation of MTG keywords
Ability Resolution🔄Resolving triggered and activated abilities

Legend:

  • ✅ Implemented and tested
  • 🔄 In progress
  • ⚠️ Planned but not yet implemented

Integration with Game UI

The Card Systems module works closely with the Game UI module to create a seamless player experience. This integration occurs through several key interfaces:

Visual Representation

The Card Rendering system provides the necessary data for the Game UI to visualize cards on screen:

  • The rendering pipeline transforms card data into visual assets
  • Dynamic updates reflect card state changes (tapped, counters, attachments)
  • Special visual effects for activated abilities and spells being cast

Deck Management

The Deck Database integrates with the UI to provide deck building and management interfaces:

  • Deck builder UI for creating and editing decks
  • Deck validation feedback
  • Importing and exporting deck lists
  • Deck statistics and analysis

User Interaction

The Card Systems module exposes interaction points that the Game UI uses to enable player actions:

  • Dragging cards between zones
  • Targeting for spells and abilities
  • Selecting options for modal abilities
  • Viewing card details and related information

State Feedback

As card states change due to game actions, this information is communicated to the UI:

  • Legal play highlighting (e.g., showing which cards can be cast)
  • Targeting validity feedback
  • Stack visualization as spells and abilities resolve

For a complete understanding of how cards are visualized and interacted with in the game, continue to the Game UI System documentation, which builds upon the foundational card systems described here.


For more detailed information about card systems, please refer to the specific subsections.

Card Database

The card database is the central repository of all card information in Rummage. It stores the canonical data for every card in the supported Magic: The Gathering sets.

Database Structure

The card database is organized to efficiently store and retrieve card data:

  • Card entries: Core card data including name, cost, types, etc.
  • Card versions: Variations of cards across different sets
  • Rules text: Formatted and parsed rules text for each card
  • Card attributes: Power, toughness, loyalty, etc.
  • Related cards: Connections between cards (tokens, meld pairs, etc.)

Data Sources

Card data is sourced from:

  • Official Wizards of the Coast databases
  • Community-maintained data repositories
  • Hand-curated rules interpretations

Implementation

The database is implemented using a combination of:

  • Static data files: Core card information stored in structured formats
  • Runtime components: In-memory representation with efficient lookups
  • Serialization: Conversion between storage and runtime formats

Loading and Caching

The database implements efficient loading and caching:

  • Lazy loading: Only load cards as needed
  • Set-based loading: Load cards by set for organized play
  • Caching: Keep frequently used cards in memory

Card Lookup

Cards can be searched by various criteria:

  • Name: Exact or fuzzy name matching
  • Mana cost: Specific mana cost or converted mana cost
  • Type: Card types, subtypes, and supertypes
  • Text: Full-text search of rules text
  • Format legality: Cards legal in specific formats

Extensibility

The database is designed for extensibility:

  • New sets: Simple process to add new card sets
  • Custom cards: Support for user-created cards
  • Format updates: Easy to update format legality

Data Structure

This document details the structure of card data in the Rummage database, explaining how card information is organized and stored.

Card Data Model

The core card data is modeled using a structured format:

Primary Card Entities

  • CardDefinition: The fundamental card entry containing all card data
  • CardEdition: Specific information for a card in a particular set
  • CardFace: Data for one face of a card (for multi-faced cards)

Core Card Data

Each card contains these core attributes:

#![allow(unused)]
fn main() {
// Simplified example of core card data structure
pub struct CardDefinition {
    pub oracle_id: Uuid,          // Unique identifier
    pub name: String,             // Card name
    pub mana_cost: Option<String>,// Mana cost string
    pub type_line: String,        // Type line text
    pub oracle_text: String,      // Oracle rules text
    pub colors: Vec<Color>,       // Card colors
    pub color_identity: Vec<Color>,// Color identity
    pub keywords: Vec<String>,    // Keyword abilities
    pub power: Option<String>,    // Power (for creatures)
    pub toughness: Option<String>,// Toughness (for creatures)
    pub loyalty: Option<String>,  // Loyalty (for planeswalkers)
    pub card_faces: Vec<CardFace>,// Multiple faces if applicable
    pub legalities: Legalities,   // Format legalities
    pub reserved: bool,           // On the reserved list?
}
}

Storage Format

Card data is stored in multiple formats:

  • JSON files: Static card data stored on disk
  • Binary format: Optimized runtime representation
  • Database: For web-based implementations

JSON Structure

The JSON structure follows a standardized format for interoperability:

{
  "oracle_id": "a3fb7228-e76b-4e96-a40e-20b5fed75685",
  "name": "Lightning Bolt",
  "mana_cost": "{R}",
  "type_line": "Instant",
  "oracle_text": "Lightning Bolt deals 3 damage to any target.",
  "colors": ["R"],
  "color_identity": ["R"],
  "keywords": [],
  "legalities": {
    "standard": "not_legal",
    "modern": "legal",
    "commander": "legal"
  }
}

Parsed Structures

Rules text and other complex fields are parsed into structured data:

Mana Cost Representation

Mana costs are parsed into a structured format:

#![allow(unused)]
fn main() {
pub struct ManaCost {
    pub generic: u32,            // Generic mana amount
    pub white: u32,              // White mana symbols
    pub blue: u32,               // Blue mana symbols
    pub black: u32,              // Black mana symbols
    pub red: u32,                // Red mana symbols
    pub green: u32,              // Green mana symbols
    pub colorless: u32,          // Colorless mana symbols
    pub phyrexian: Vec<Color>,   // Phyrexian mana symbols
    pub hybrid: Vec<(Color, Color)>, // Hybrid mana pairs
    pub x: bool,                 // Contains X in cost?
}
}

Rules Text Parsing

Rules text is parsed into an abstract syntax tree:

#![allow(unused)]
fn main() {
pub enum RulesTextNode {
    Text(String),
    Keyword(KeywordAbility),
    TriggeredAbility {
        trigger: Trigger,
        effect: Effect,
    },
    ActivatedAbility {
        cost: Vec<Cost>,
        effect: Effect,
    },
    StaticAbility(StaticEffect),
}
}

Card Relationships

The database tracks relationships between cards:

  • Token creators: Cards that create tokens
  • Meld pairs: Cards that meld together
  • Companions: Cards with companion relationships
  • Partners: Cards with partner abilities
  • Flip sides: Two sides of double-faced cards

Indexing and Lookup

The data structure includes optimized indexes for:

  • Name lookup: Fast retrieval by card name
  • Type lookup: Finding cards by type
  • Text search: Finding cards with specific rules text
  • Color lookup: Finding cards by color identity
  • Format lookup: Finding cards legal in specific formats

Data Versioning

The database supports versioning of card data:

  • Oracle updates: Tracking rules text changes
  • Erratas: Handling card corrections
  • Set releases: Managing new card additions
  • Format changes: Updating format legalities

Card Attributes

This document details the attributes tracked for each card in the Rummage database. These attributes define a card's characteristics, behavior, and appearance.

Core Attributes

Identification

  • Oracle ID: Unique identifier for the card's Oracle text
  • Name: The card's name
  • Set Code: The three or four-letter code for the card's set
  • Collector Number: The card's number within its set
  • Rarity: Common, uncommon, rare, mythic rare, or special

Card Type Information

  • Type Line: The full type line text
  • Supertypes: Legendary, Basic, Snow, etc.
  • Card Types: Creature, Instant, Sorcery, etc.
  • Subtypes: Human, Wizard, Equipment, Aura, etc.

Mana and Color

  • Mana Cost: The card's mana cost
  • Mana Value: The converted mana cost (total mana)
  • Colors: The card's colors
  • Color Identity: Colors in mana cost and rules text
  • Color Indicator: For cards with no mana cost but have a color

Rules Text

  • Oracle Text: The official rules text
  • Flavor Text: The card's flavor text
  • Keywords: Keyword abilities (Flying, Trample, etc.)
  • Ability Words: Words that have no rules meaning (Landfall, etc.)

Combat Stats

  • Power: Creature's power (attack strength)
  • Toughness: Creature's toughness (health)
  • Loyalty: Starting loyalty for planeswalkers
  • Defense: Defense value for battles

Legality

  • Format Legality: Legal status in various formats
  • Reserved List: Whether the card is on the Reserved List
  • Banned/Restricted: Status in various formats

Special Card Attributes

Multi-faced Cards

  • Card Faces: Data for each face of the card
  • Layout: Card layout type (normal, split, flip, etc.)
  • Face Relationship: How faces relate to each other

Tokens

  • Token Information: Data for tokens created by the card
  • Token Colors: Colors of created tokens
  • Token Types: Types of created tokens

Counters

  • Counter Types: Types of counters the card uses
  • Counter Placement: Where counters can be placed
  • Counter Effects: Effects of counters on the card

Implementation

Card attributes are implemented as components in the ECS:

#![allow(unused)]
fn main() {
// Example of card type components
#[derive(Component)]
pub struct CardName(pub String);

#[derive(Component)]
pub struct ManaCost {
    pub cost_string: String,
    pub parsed: ParsedManaCost,
}

#[derive(Component)]
pub struct CardTypes {
    pub supertypes: Vec<Supertype>,
    pub card_types: Vec<CardType>,
    pub subtypes: Vec<Subtype>,
}

#[derive(Component)]
pub struct OracleText(pub String);

#[derive(Component)]
pub struct PowerToughness {
    pub power: String,
    pub toughness: String,
}
}

Attribute Modification

Card attributes can be modified by:

  • Static Effects: Continuous modifications
  • One-time Effects: Temporary changes
  • Counters: Modifications from counters
  • State-Based Actions: Automatic modifications

Attribute Access

Systems access card attributes through queries:

#![allow(unused)]
fn main() {
// Example of a system that queries card attributes
fn check_creature_types(
    creatures: Query<(Entity, &CardTypes, &CardName)>,
) {
    for (entity, card_types, name) in creatures.iter() {
        if card_types.card_types.contains(&CardType::Creature) {
            // Process creature card
            let creature_subtypes = &card_types.subtypes;
            // ...
        }
    }
}
}

Attribute Serialization

Attributes are serialized for:

  • Persistence: Saving game state
  • Networking: Transmitting card data
  • UI Display: Showing card information

Deck Database

The deck database in Rummage is a system for managing player decks, including creation, storage, validation, and manipulation during gameplay. This section documents the deck database architecture and implementation.

Overview

The deck database provides the following functionality:

  • Deck Creation: Building and configuring new decks
  • Storage: Persistent storage of deck data
  • Validation: Checking decks against format rules
  • Runtime Manipulation: In-game deck operations like drawing and shuffling
  • Registry: Managing a collection of predefined and custom decks

Architecture

The deck database is implemented using a combination of:

  • Disk Storage: JSON-based storage for deck persistence
  • In-Memory Representation: Runtime deck entities and components
  • Deck Registry: A global registry for decks available to all players
  • Player-Specific Decks: Per-player deck instances

Core Components

The deck database consists of these key components:

  • Deck Structure: The core Deck type and its related components
  • Deck Builder: A builder pattern for constructing decks
  • Deck Registry: A resource for registering and retrieving decks
  • Deck Validation: Format-specific validation systems
  • Deck Persistence: Saving and loading deck configurations

Integration with Game Systems

The deck database integrates with other game systems:

  • Card Database: Drawing cards from the core card database
  • Player Systems: Assigning decks to players
  • Game Rules: Format-specific deck constraints
  • UI Systems: Deck building and viewing interfaces

Format Support

The deck database supports multiple deck formats:

  • Commander/EDH: 100-card singleton decks with a commander
  • Standard: 60-card minimum with 4-copy maximum per card
  • Modern/Legacy/Vintage: Format-specific banned and restricted lists
  • Limited: 40-card minimum built from a limited card pool
  • Custom: User-defined formats with custom rules

Deck Structure

This document details the core data structures used to represent decks in Rummage.

Core Types

The deck system is built around several key types:

Deck

The Deck struct is the fundamental representation of a deck in Rummage:

#![allow(unused)]
fn main() {
/// Represents a deck of Magic cards
#[derive(Debug, Clone)]
pub struct Deck {
    /// Name of the deck
    pub name: String,
    /// Type of the deck (Commander, Standard, etc.)
    pub deck_type: DeckType,
    /// Cards in the deck
    pub cards: Vec<Card>,
    /// Commander card ID if this is a Commander deck
    pub commander: Option<Entity>,
    /// Partner commander card ID if applicable
    pub partner: Option<Entity>,
    /// Owner of the deck
    pub owner: Option<Entity>,
}
}

This structure contains all essential information about a deck, including its contents, format type, and ownership details.

PlayerDeck

The PlayerDeck component attaches a deck to a player entity in the ECS:

#![allow(unused)]
fn main() {
/// Component to track a player's deck
#[derive(Component, Debug, Clone)]
pub struct PlayerDeck {
    /// The actual deck data
    pub deck: Deck,
}
}

This wrapper allows decks to be proper ECS components that can be independently queried and modified.

DeckType

The DeckType enum defines the supported formats:

#![allow(unused)]
fn main() {
/// Represents different types of Magic decks
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum DeckType {
    /// Standard format deck (60 card minimum)
    Standard,
    /// Commander/EDH format deck (100 card singleton with Commander)
    Commander,
    /// Modern format deck
    Modern,
    /// Legacy format deck
    Legacy,
    /// Vintage format deck
    Vintage,
    /// Pauper format deck
    Pauper,
    /// Pioneer format deck
    Pioneer,
    /// Limited format deck (40 card minimum)
    Limited,
    /// Brawl format deck
    Brawl,
    /// Custom format with special rules
    Custom(String),
}
}

Each format has specific rules for deck construction and validation.

Deck Operations

The Deck struct provides methods for common operations:

Creation and Setup

#![allow(unused)]
fn main() {
// Create a new deck
pub fn new(name: String, deck_type: DeckType, cards: Vec<Card>) -> Self

// Set the owner of this deck
pub fn set_owner(&mut self, owner: Entity)

// Set the commander for this deck
pub fn set_commander(&mut self, commander: Entity)

// Set the partner commander for this deck
pub fn set_partner(&mut self, partner: Entity)
}

Deck Manipulation

#![allow(unused)]
fn main() {
// Shuffle the deck
pub fn shuffle(&mut self)

// Draw a card from the top of the deck
pub fn draw(&mut self) -> Option<Card>

// Draw multiple cards from the top of the deck
pub fn draw_multiple(&mut self, count: usize) -> Vec<Card>

// Add a card to the top of the deck
pub fn add_top(&mut self, card: Card)

// Add a card to the bottom of the deck
pub fn add_bottom(&mut self, card: Card)
}

Deck Analysis

#![allow(unused)]
fn main() {
// Get the number of cards in the deck
pub fn card_count(&self) -> usize

// Search for cards by name
pub fn search(&self, name: &str) -> Vec<&Card>

// Validate the deck against format rules
pub fn validate(&self) -> Result<(), DeckValidationError>
}

Player Deck Operations

The PlayerDeck component provides its own convenience methods:

#![allow(unused)]
fn main() {
// Create a new player deck component
pub fn new(deck: Deck) -> Self

// Draw a card from the top of the deck
pub fn draw(&mut self) -> Option<Card>

// Draw multiple cards from the top of the deck
pub fn draw_multiple(&mut self, count: usize) -> Vec<Card>
}

Validation

Deck validation is format-specific and can return various error types:

#![allow(unused)]
fn main() {
/// Errors that can occur during deck validation
#[derive(Debug)]
pub enum DeckValidationError {
    /// Deck doesn't have enough cards
    TooFewCards { required: usize, actual: usize },
    /// Deck has illegal cards (e.g., banned cards)
    IllegalCards(Vec<String>),
    /// Deck has too many copies of a card
    TooManyCopies {
        card_name: String,
        max_allowed: usize,
        actual: usize,
    },
    /// Deck has cards outside the Commander's color identity
    ColorIdentityViolation(Vec<String>),
    /// Commander is missing
    MissingCommander,
    /// Other validation errors
    OtherError(String),
}
}

Deck Builder

The deck builder system provides a flexible API for creating and configuring Magic decks in Rummage. It uses the builder pattern to provide a fluent interface for deck construction.

Builder Pattern

The DeckBuilder struct follows the builder pattern, allowing incremental construction of a deck with clear, chainable methods:

#![allow(unused)]
fn main() {
/// Builder for creating decks
#[derive(Default)]
pub struct DeckBuilder {
    name: Option<String>,
    deck_type: Option<DeckType>,
    cards: Vec<Card>,
    commander: Option<Entity>,
    partner: Option<Entity>,
    owner: Option<Entity>,
}
}

This pattern makes deck creation more readable and easier to maintain.

Basic Usage

Creating a standard 60-card deck:

#![allow(unused)]
fn main() {
let deck = DeckBuilder::new()
    .with_name("My Standard Deck")
    .with_type(DeckType::Standard)
    .with_cards(my_cards)
    .with_owner(player_entity)
    .build()?;
}

Creating a Commander deck:

#![allow(unused)]
fn main() {
let deck = DeckBuilder::new()
    .with_name("My Commander Deck")
    .with_type(DeckType::Commander)
    .with_cards(main_deck_cards)
    .with_commander(commander_entity)
    .with_owner(player_entity)
    .build()?;
}

Available Methods

The DeckBuilder provides these core methods:

Initialization

#![allow(unused)]
fn main() {
// Create a new empty deck builder
pub fn new() -> Self
}

Configuration

#![allow(unused)]
fn main() {
// Set the name of the deck
pub fn with_name(mut self, name: &str) -> Self

// Set the type of the deck
pub fn with_type(mut self, deck_type: DeckType) -> Self

// Set the commander (for Commander format)
pub fn with_commander(mut self, commander: Entity) -> Self

// Set the partner commander (for Commander format)
pub fn with_partner(mut self, partner: Entity) -> Self

// Set the owner of the deck
pub fn with_owner(mut self, owner: Entity) -> Self
}

Card Management

#![allow(unused)]
fn main() {
// Add multiple cards at once
pub fn with_cards(mut self, cards: Vec<Card>) -> Self

// Add a single card
pub fn add_card(mut self, card: Card) -> Self

// Add multiple copies of a card
pub fn add_copies(mut self, card: Card, count: usize) -> Self
}

Building

#![allow(unused)]
fn main() {
// Build the final deck
pub fn build(self) -> Result<Deck, String>

// Build a shuffled deck
pub fn build_shuffled(self) -> Result<Deck, String>
}

Implementation Details

The builder handles default values for optional fields:

#![allow(unused)]
fn main() {
pub fn build(self) -> Result<Deck, String> {
    let name = self.name.unwrap_or_else(|| "Untitled Deck".to_string());
    let deck_type = self.deck_type.unwrap_or(DeckType::Standard);

    let mut deck = Deck::new(name, deck_type, self.cards);

    if let Some(commander) = self.commander {
        deck.set_commander(commander);
    }

    if let Some(partner) = self.partner {
        deck.set_partner(partner);
    }

    if let Some(owner) = self.owner {
        deck.set_owner(owner);
    }

    Ok(deck)
}
}

This ensures that decks always have reasonable defaults even when not all fields are specified.

Format-Specific Usage

Commander Decks

When building Commander decks, additional fields are required:

#![allow(unused)]
fn main() {
let deck = DeckBuilder::new()
    .with_name("Atraxa Superfriends")
    .with_type(DeckType::Commander)
    .with_cards(deck_cards)
    .with_commander(atraxa_entity)
    .build()?;
}

Partner Commanders

For decks with partner commanders:

#![allow(unused)]
fn main() {
let deck = DeckBuilder::new()
    .with_name("Partners Deck")
    .with_type(DeckType::Commander)
    .with_cards(deck_cards)
    .with_commander(thrasios_entity)
    .with_partner(tymna_entity)
    .build()?;
}

Validation

The builder doesn't perform validation during construction. Validation is handled separately when needed:

#![allow(unused)]
fn main() {
let deck = deck_builder.build()?;
if let Err(validation_error) = deck.validate() {
    // Handle validation error
}
}

This separation allows for creating incomplete or invalid decks when necessary (e.g., during deck construction in the UI).

Deck Registry

The Deck Registry is a global resource that manages collections of predefined and user-created decks. It provides a central repository for deck storage, retrieval, and management.

Registry Resource

The DeckRegistry is implemented as a Bevy resource:

#![allow(unused)]
fn main() {
#[derive(Resource, Default)]
pub struct DeckRegistry {
    decks: std::collections::HashMap<String, Deck>,
}
}

This resource is initialized during application startup:

#![allow(unused)]
fn main() {
// In the DeckPlugin implementation
fn build(&self, app: &mut App) {
    app.init_resource::<DeckRegistry>()
       .add_systems(Startup, register_default_decks)
       .add_systems(Startup, shuffle_all_player_decks);
}
}

Core Registry Operations

The DeckRegistry provides several key methods:

Registration

#![allow(unused)]
fn main() {
// Register a deck with the registry
pub fn register_deck(&mut self, name: &str, deck: Deck) {
    self.decks.insert(name.to_string(), deck);
}
}

Retrieval

#![allow(unused)]
fn main() {
// Get a specific deck by name
pub fn get_deck(&self, name: &str) -> Option<&Deck> {
    self.decks.get(name)
}

// Get all registered decks
pub fn get_all_decks(&self) -> Vec<(&String, &Deck)> {
    self.decks.iter().collect()
}
}

Default Decks

The registry includes a startup system that registers default decks:

#![allow(unused)]
fn main() {
// Register default decks for testing/examples
fn register_default_decks(mut registry: ResMut<DeckRegistry>) {
    // Register predefined decks
    // These could be loaded from files, created programmatically, etc.
    
    // Example: Register a basic test deck
    let test_deck = create_test_deck();
    registry.register_deck("Test Deck", test_deck);
    
    // Example: Register Commander precons
    let precon_decks = create_precon_decks();
    for (name, deck) in precon_decks {
        registry.register_deck(&name, deck);
    }
}
}

Integration with Player Systems

The registry is designed to work with player-specific deck instances:

#![allow(unused)]
fn main() {
// System to assign decks to players from the registry
fn assign_decks_to_players(
    registry: Res<DeckRegistry>,
    mut commands: Commands,
    players: Query<(Entity, &PlayerPreferences)>,
) {
    for (player_entity, preferences) in players.iter() {
        if let Some(preferred_deck) = preferences.preferred_deck.as_ref() {
            if let Some(deck) = registry.get_deck(preferred_deck) {
                // Clone the deck from the registry
                let player_deck = PlayerDeck::new(deck.clone());
                
                // Assign the deck to the player
                commands.entity(player_entity).insert(player_deck);
            }
        }
    }
}
}

Custom Deck Registration

Players can register their own custom decks:

#![allow(unused)]
fn main() {
// Register a player's custom deck
fn register_custom_deck(
    mut registry: ResMut<DeckRegistry>,
    deck_builder: DeckBuilder,
    player_name: &str,
) -> Result<(), String> {
    let deck_name = format!("{}'s Custom Deck", player_name);
    let deck = deck_builder.build()?;
    
    registry.register_deck(&deck_name, deck);
    Ok(())
}
}

Deck Shuffling

The registry works with a system that ensures all player decks are properly shuffled:

#![allow(unused)]
fn main() {
// System to ensure all player decks are properly shuffled independently
fn shuffle_all_player_decks(mut player_decks: Query<&mut PlayerDeck>) {
    for mut player_deck in player_decks.iter_mut() {
        player_deck.deck.shuffle();
    }
}
}

Persistence

In a complete implementation, the registry also handles saving and loading decks to/from disk:

#![allow(unused)]
fn main() {
// Save all registered decks to disk
pub fn save_all_decks(&self, path: &Path) -> Result<(), io::Error> {
    // Implementation for serializing and saving decks
}

// Load decks from disk
pub fn load_decks(&mut self, path: &Path) -> Result<(), io::Error> {
    // Implementation for loading and deserializing decks
}
}

Format Validation

The format validation system ensures that decks comply with the rules of their respective formats. Each Magic: The Gathering format has specific deck construction requirements that must be validated.

Validation Process

Deck validation is performed through the validate method on the Deck struct:

#![allow(unused)]
fn main() {
impl Deck {
    pub fn validate(&self) -> Result<(), DeckValidationError> {
        match self.deck_type {
            DeckType::Commander => self.validate_commander(),
            DeckType::Standard => self.validate_standard(),
            DeckType::Modern => self.validate_modern(),
            DeckType::Legacy => self.validate_legacy(),
            DeckType::Vintage => self.validate_vintage(),
            DeckType::Pauper => self.validate_pauper(),
            DeckType::Pioneer => self.validate_pioneer(),
            DeckType::Limited => self.validate_limited(),
            DeckType::Brawl => self.validate_brawl(),
            DeckType::Custom(ref _custom) => Ok(()), // Custom formats don't have fixed validation
        }
    }
}
}

Validation Errors

Validation failures return a DeckValidationError that describes the specific issue:

#![allow(unused)]
fn main() {
pub enum DeckValidationError {
    /// Deck doesn't have enough cards
    TooFewCards { required: usize, actual: usize },
    /// Deck has illegal cards (e.g., banned cards)
    IllegalCards(Vec<String>),
    /// Deck has too many copies of a card
    TooManyCopies {
        card_name: String,
        max_allowed: usize,
        actual: usize,
    },
    /// Deck has cards outside the Commander's color identity
    ColorIdentityViolation(Vec<String>),
    /// Commander is missing
    MissingCommander,
    /// Other validation errors
    OtherError(String),
}
}

Format-Specific Validation

Each format has its own validation rules implemented as a private method:

Commander Validation

#![allow(unused)]
fn main() {
fn validate_commander(&self) -> Result<(), DeckValidationError> {
    // Check deck size (100 cards including commander)
    if self.cards.len() < 99 {
        return Err(DeckValidationError::TooFewCards {
            required: 99,
            actual: self.cards.len(),
        });
    }

    // Check if commander exists
    if self.commander.is_none() {
        return Err(DeckValidationError::MissingCommander);
    }

    // Check singleton rule (except basic lands)
    let mut card_counts = std::collections::HashMap::new();
    for card in &self.cards {
        if !card.is_basic_land() {
            *card_counts.entry(&card.name).or_insert(0) += 1;
        }
    }

    for (card_name, count) in card_counts {
        if count > 1 {
            return Err(DeckValidationError::TooManyCopies {
                card_name: card_name.to_string(),
                max_allowed: 1,
                actual: count,
            });
        }
    }

    // Check color identity
    if let Some(commander_entity) = self.commander {
        // Logic to check that all cards match commander's color identity
        // ...
    }

    Ok(())
}
}

Standard Validation

#![allow(unused)]
fn main() {
fn validate_standard(&self) -> Result<(), DeckValidationError> {
    // Check minimum deck size (60 cards)
    if self.cards.len() < 60 {
        return Err(DeckValidationError::TooFewCards {
            required: 60,
            actual: self.cards.len(),
        });
    }

    // Check card copy limit (max 4 of any card except basic lands)
    let mut card_counts = std::collections::HashMap::new();
    for card in &self.cards {
        if !card.is_basic_land() {
            *card_counts.entry(&card.name).or_insert(0) += 1;
        }
    }

    for (card_name, count) in card_counts {
        if count > 4 {
            return Err(DeckValidationError::TooManyCopies {
                card_name: card_name.to_string(),
                max_allowed: 4,
                actual: count,
            });
        }
    }

    // Check for banned cards
    let banned_cards: Vec<_> = self.cards
        .iter()
        .filter(|card| is_banned_in_standard(card))
        .map(|card| card.name.clone())
        .collect();

    if !banned_cards.is_empty() {
        return Err(DeckValidationError::IllegalCards(banned_cards));
    }

    // Check set legality
    let illegal_sets: Vec<_> = self.cards
        .iter()
        .filter(|card| !is_legal_in_standard_sets(card))
        .map(|card| card.name.clone())
        .collect();

    if !illegal_sets.is_empty() {
        return Err(DeckValidationError::IllegalCards(illegal_sets));
    }

    Ok(())
}
}

Format Rules Implementation

The validation system relies on several helper functions:

#![allow(unused)]
fn main() {
// Check if a card is a basic land
fn is_basic_land(card: &Card) -> bool {
    // Implementation to check if a card is a basic land
}

// Check if a card is banned in a specific format
fn is_banned_in_format(card: &Card, format: &DeckType) -> bool {
    // Implementation to check format-specific ban lists
}

// Check if a card's set is legal in the standard rotation
fn is_legal_in_standard_sets(card: &Card) -> bool {
    // Implementation to check standard set legality
}

// Get a card's color identity
fn get_color_identity(card: &Card) -> Vec<Color> {
    // Implementation to determine a card's color identity
}
}

UI Integration

The validation system integrates with the deck builder UI to provide immediate feedback:

#![allow(unused)]
fn main() {
// System to validate decks in the deck builder UI
fn validate_deck_in_builder(
    deck: &Deck,
    mut validation_state: &mut ValidationState,
) {
    match deck.validate() {
        Ok(()) => {
            validation_state.is_valid = true;
            validation_state.errors.clear();
        }
        Err(error) => {
            validation_state.is_valid = false;
            validation_state.errors.push(error);
        }
    }
}
}

Validation Timing

Validation is performed at several key points:

  1. During deck building: Validate as cards are added/removed
  2. Before saving: Ensure decks are valid before saving to the registry
  3. Before game start: Verify all player decks are valid for the format
  4. After format changes: Re-validate when card legality changes

Persistent Storage with bevy_persistent

This document details the implementation of robust save/load functionality for decks using bevy_persistent, a crate that provides efficient and reliable persistence for Bevy resources.

Introduction to bevy_persistent

bevy_persistent is a crate that enables easy persistence of Bevy resources to disk with automatic serialization and deserialization. It provides:

  • Automatic saving: Resources are automatically saved when modified
  • Error handling: Robust error handling and recovery
  • Hot reloading: Changes to saved files can be detected and loaded at runtime
  • Format flexibility: Support for various serialization formats (JSON, RON, TOML, etc.)
  • Path configuration: Flexible configuration of save paths

Integrating bevy_persistent with DeckRegistry

The DeckRegistry resource can be enhanced with bevy_persistent to provide automatic, robust persistence:

#![allow(unused)]
fn main() {
use bevy::prelude::*;
use bevy_persistent::prelude::*;
use serde::{Deserialize, Serialize};

/// Persistent registry for storing decks
#[derive(Resource, Serialize, Deserialize, Default)]
pub struct PersistentDeckRegistry {
    /// All registered decks
    pub decks: std::collections::HashMap<String, Deck>,
    /// Last saved timestamp
    #[serde(skip)]
    pub last_saved: Option<std::time::SystemTime>,
}

// Extension for the DeckPlugin
impl DeckPlugin {
    fn build(&self, app: &mut App) {
        // Initialize the persistent deck registry
        let persistent_registry = Persistent::<PersistentDeckRegistry>::builder()
            .name("decks")
            .format(StorageFormat::Ron)
            .path("user://deck_registry.ron")
            .default(PersistentDeckRegistry::default())
            .build();

        app.insert_resource(persistent_registry)
            .add_systems(Update, autosave_registry)
            .add_systems(Startup, load_decks_on_startup);
    }
}
}

Autosave System

The autosave system ensures decks are saved whenever they are modified:

#![allow(unused)]
fn main() {
/// System to automatically save the deck registry when modified
fn autosave_registry(
    mut registry: ResMut<Persistent<PersistentDeckRegistry>>,
    time: Res<Time>,
) {
    // Check if registry was modified since last save
    if registry.is_changed() {
        // Only save every few seconds to avoid excessive disk I/O
        let now = std::time::SystemTime::now();
        let should_save = match registry.last_saved {
            Some(last_saved) => {
                now.duration_since(last_saved)
                    .unwrap_or_default()
                    .as_secs() >= 5
            }
            None => true,
        };

        if should_save {
            info!("Auto-saving deck registry...");
            if let Err(err) = registry.save() {
                error!("Failed to save deck registry: {}", err);
            } else {
                registry.last_saved = Some(now);
                info!("Deck registry saved successfully");
            }
        }
    }
}
}

Loading Decks on Startup

Decks are automatically loaded when the application starts:

#![allow(unused)]
fn main() {
/// System to load decks on startup
fn load_decks_on_startup(
    mut registry: ResMut<Persistent<PersistentDeckRegistry>>,
    mut commands: Commands,
) {
    info!("Loading deck registry from persistent storage...");
    
    // Try to load the registry from disk
    match registry.load() {
        Ok(_) => {
            info!("Successfully loaded {} decks from registry", registry.decks.len());
            
            // Additional setup for loaded decks if needed
            for (name, deck) in registry.decks.iter() {
                debug!("Loaded deck: {}", name);
            }
        }
        Err(err) => {
            error!("Failed to load deck registry: {}", err);
            info!("Using default empty registry instead");
        }
    }
}
}

API for Deck Management

The persistent registry provides a clean API for deck management:

#![allow(unused)]
fn main() {
/// Add a deck to the registry and save it
pub fn add_deck(
    mut registry: ResMut<Persistent<PersistentDeckRegistry>>,
    name: &str,
    deck: Deck,
) -> Result<(), String> {
    info!("Adding deck '{}' to registry", name);
    registry.decks.insert(name.to_string(), deck);
    
    match registry.save() {
        Ok(_) => {
            info!("Deck '{}' added and saved successfully", name);
            Ok(())
        }
        Err(err) => {
            error!("Failed to save deck registry after adding '{}': {}", name, err);
            Err(format!("Failed to save: {}", err))
        }
    }
}

/// Remove a deck from the registry
pub fn remove_deck(
    mut registry: ResMut<Persistent<PersistentDeckRegistry>>,
    name: &str,
) -> Result<(), String> {
    if registry.decks.remove(name).is_none() {
        return Err(format!("Deck '{}' not found in registry", name));
    }
    
    match registry.save() {
        Ok(_) => {
            info!("Deck '{}' removed and registry saved", name);
            Ok(())
        }
        Err(err) => {
            error!("Failed to save registry after removing '{}': {}", name, err);
            Err(format!("Failed to save: {}", err))
        }
    }
}
}

Error Recovery

The system includes mechanisms for error recovery in case of corruption:

#![allow(unused)]
fn main() {
/// System to handle corrupted deck files
fn handle_corrupted_registry(
    mut registry: ResMut<Persistent<PersistentDeckRegistry>>,
) {
    // If loading failed due to deserialization errors
    if let Err(PersistentError::Deserialize(_)) = registry.try_load() {
        warn!("Deck registry file appears to be corrupted");
        
        // Create a backup of the corrupted file
        if let Some(path) = registry.path() {
            let backup_path = format!("{}.backup", path.display());
            if let Err(e) = std::fs::copy(path, backup_path.clone()) {
                error!("Failed to create backup of corrupted file: {}", e);
            } else {
                info!("Created backup of corrupted file at {}", backup_path);
            }
        }
        
        // Reset to default and save
        *registry = Persistent::builder()
            .name("decks")
            .format(StorageFormat::Ron)
            .path("user://deck_registry.ron")
            .default(PersistentDeckRegistry::default())
            .build();
            
        if let Err(e) = registry.save() {
            error!("Failed to save new default registry: {}", e);
        } else {
            info!("Reset deck registry to default state");
        }
    }
}
}

Integration with Player Systems

The persistent deck registry can be integrated with player systems:

#![allow(unused)]
fn main() {
/// System to assign persistent decks to players
fn assign_persistent_decks(
    registry: Res<Persistent<PersistentDeckRegistry>>,
    mut commands: Commands,
    players: Query<(Entity, &PlayerPreferences)>,
) {
    for (player_entity, preferences) in players.iter() {
        if let Some(preferred_deck) = preferences.preferred_deck.as_ref() {
            if let Some(deck) = registry.decks.get(preferred_deck) {
                // Clone the deck from the registry
                let player_deck = PlayerDeck::new(deck.clone());
                
                // Assign the deck to the player
                commands.entity(player_entity).insert(player_deck);
                
                info!("Assigned deck '{}' to player", preferred_deck);
            }
        }
    }
}
}

Benefits Over Manual Persistence

Using bevy_persistent offers several advantages over manual file I/O:

  1. Automatic Change Detection: Resources are only saved when actually modified
  2. Error Handling: Built-in error recovery mechanisms
  3. Hot Reloading: Changes to deck files can be detected at runtime
  4. Format Flexibility: Easy switching between serialization formats
  5. Path Management: Cross-platform handling of save paths

Card Effects

Card effects are the mechanisms through which cards interact with the game state. This section documents how card effects are implemented, resolved, and tested in Rummage.

Overview

Magic: The Gathering cards have a wide variety of effects that modify the game state in different ways:

  • Adding or removing counters
  • Dealing damage
  • Moving cards between zones
  • Modifying attributes of other cards
  • Creating tokens
  • Altering the rules of the game

The effect system in Rummage is designed to handle all these interactions in a consistent and extensible way.

Implementation Approach

Effects are implemented using a combination of:

  • Components: Data that defines the effect's properties
  • Systems: Logic that processes and applies the effects
  • Events: Notifications that trigger and respond to effects
  • Queries: Selections of target entities to affect

Core Components

Integration

The effects system integrates with:

Testing

For information on how to test card effects, see Effect Verification.

Effect Resolution

Effect resolution is the process by which a card's effects are applied to the game state. This document outlines how Rummage implements this core game mechanic.

Resolution Process

The resolution of an effect follows these steps:

  1. Validation: Check if the effect can legally resolve
  2. Target Confirmation: Verify targets are still legal
  3. Effect Application: Apply the effect to the game state
  4. Triggered Abilities: Check for abilities triggered by the effect
  5. State-Based Actions: Check for state-based actions after resolution

Implementation

Effect resolution is implemented using Bevy's entity-component-system architecture:

#![allow(unused)]
fn main() {
// Example of a system that resolves damage effects
fn resolve_damage_effects(
    mut commands: Commands,
    mut event_reader: EventReader<ResolveEffectEvent>,
    mut damage_effects: Query<(Entity, &DamageEffect, &EffectTargets)>,
    mut targets: Query<&mut Health>,
    mut damaged_event_writer: EventWriter<DamagedEvent>
) {
    for resolve_event in event_reader.read() {
        if let Ok((effect_entity, damage_effect, effect_targets)) = 
            damage_effects.get(resolve_event.effect_entity) {
            
            // Apply damage to each target
            for &target in &effect_targets.entities {
                if let Ok(mut health) = targets.get_mut(target) {
                    health.current -= damage_effect.amount;
                    
                    // Send a damage event for other systems to react to
                    damaged_event_writer.send(DamagedEvent {
                        entity: target,
                        amount: damage_effect.amount,
                        source: resolve_event.source_entity,
                    });
                }
            }
            
            // Clean up the resolved effect
            commands.entity(effect_entity).despawn();
        }
    }
}
}

Effect Types

Different types of effects have specialized resolution procedures:

One-Shot Effects

These effects happen once and are immediately complete:

  • Damage dealing
  • Card drawing
  • Life gain/loss

Continuous Effects

These effects modify the game state for a duration:

  • Static abilities
  • Enchantment effects
  • "Until end of turn" effects

Replacement Effects

These effects replace one event with another:

  • Damage prevention
  • Alternative costs
  • "Instead" effects

Effect Timing

Effects respect the timing rules of Magic:

  • Instant speed: Can be played anytime priority is held
  • Sorcery speed: Only during main phases of the controller's turn
  • Triggered: When certain conditions are met
  • Static: Continuous effects that always apply

Error Handling

Effect resolution includes error handling for cases where:

  • Targets become illegal
  • Effect requirements can't be met
  • Rules prevent the effect from resolving

Targeting

Targeting is a fundamental mechanic in Magic: The Gathering that determines which game objects (cards, players, etc.) will be affected by a spell or ability. This document outlines how targeting is implemented in Rummage.

Core Concepts

Target Types

The targeting system supports various target types:

  • Cards: Cards in any zone
  • Players: Players in the game
  • Tokens: Token creatures on the battlefield
  • Spells: Spells on the stack
  • Emblems: Persistent effects in the command zone
  • Abilities: Abilities on the stack

Target Restrictions

Targets can be restricted based on:

  • Characteristics: Card type, subtypes, color, power/toughness, etc.
  • Game State: Tapped/untapped, controller, zone, etc.
  • Custom Conditions: Any programmable condition

Implementation

Target Selection

Target selection is implemented using queries and predicates:

#![allow(unused)]
fn main() {
// Example of a targeting predicate for "target creature you control"
pub fn creature_you_control_predicate(
    entity: Entity,
    world: &World,
    source_player: Entity,
) -> bool {
    let card_type = world.get::<CardType>(entity);
    let controller = world.get::<Controller>(entity);
    let zone = world.get::<Zone>(entity);
    
    matches!(card_type, Ok(CardType::Creature)) &&
    matches!(controller, Ok(Controller(player)) if *player == source_player) &&
    matches!(zone, Ok(Zone::Battlefield))
}
}

Target Validation

Targets are validated at multiple points:

  1. During casting/activation: Initial target selection
  2. On resolution: Confirming targets are still legal
  3. During targeting events: When other effects change targeting

Illegal Targets

The system handles illegal targets by:

  • Preventing spells with illegal targets from being cast
  • Countering spells with illegal targets on resolution
  • Removing illegal targets from multi-target effects

UI Integration

Targeting integrates with the UI system:

  • Visual indicators: Highlighting valid targets
  • Targeting arrows: Showing connections between source and targets
  • Invalid target feedback: Indicating why a target is invalid

Special Cases

Protection

The system handles protection effects:

  • Can't be targeted by spells/abilities with specific characteristics
  • Can't be blocked by creatures with specific characteristics
  • Can't be damaged by sources with specific characteristics
  • Can't be enchanted/equipped by specific characteristics

Hexproof and Shroud

  • Hexproof: Can't be targeted by spells/abilities opponents control
  • Shroud: Can't be targeted by any spells/abilities

Changing Targets

The system supports effects that change targets:

  • Redirect effects
  • "Change the target" effects
  • Copying with new targets

Complex Interactions

Magic: The Gathering is known for its intricate rule system and complex card interactions. This document outlines how Rummage handles these complex card interactions to ensure correct game behavior.

Layering System

MTG uses a layering system to determine how continuous effects are applied. Rummage implements this system following the comprehensive rules:

Layer Order

  1. Copy effects: Effects that make an object a copy of another object
  2. Control-changing effects: Effects that change control of an object
  3. Text-changing effects: Effects that change the text of an object
  4. Type-changing effects: Effects that change the types of an object
  5. Color-changing effects: Effects that change the colors of an object
  6. Ability-adding/removing effects: Effects that add or remove abilities
  7. Power/toughness-changing effects: Effects that modify power and toughness

Implementation

The layering system is implemented using a multi-pass approach:

#![allow(unused)]
fn main() {
// Example of layered effect application
fn apply_continuous_effects(
    mut card_entities: Query<(Entity, &mut CardState)>,
    continuous_effects: Query<&ContinuousEffect>,
    timestamps: Query<&Timestamp>,
) {
    // Collect all effects
    let mut effects_by_layer = [Vec::new(); 7];
    for (entity, effect) in continuous_effects.iter() {
        effects_by_layer[effect.layer as usize].push((entity, effect));
    }
    
    // Sort effects within each layer by timestamp
    for layer_effects in &mut effects_by_layer {
        layer_effects.sort_by_key(|(entity, _)| timestamps.get(*entity).unwrap().0);
    }
    
    // Apply effects layer by layer
    for (layer_idx, layer_effects) in effects_by_layer.iter().enumerate() {
        for (_, effect) in layer_effects {
            apply_effect_in_layer(layer_idx, effect, &mut card_entities);
        }
    }
}
}

Dependency Chains

Some effects depend on the outcome of other effects. The system handles these dependencies by:

  1. Building a dependency graph of effects
  2. Topologically sorting the graph
  3. Applying effects in the correct order

State-Based Action Loops

Some complex interactions can create loops of state-based actions. The system:

  1. Detects potential loops
  2. Applies a maximum iteration limit
  3. Resolves terminal states correctly

Replacement Effects

Replacement effects modify how events occur. They're handled by:

  1. Tracking the original event
  2. Applying applicable replacement effects
  3. Determining the order when multiple effects apply
  4. Generating the modified event

Triggered Ability Resolution

When multiple abilities trigger simultaneously:

  1. APNAP order is used (Active Player, Non-Active Player)
  2. The controller of each ability chooses the order for their triggers
  3. Abilities are placed on the stack in that order

Example: Layers in Action

Here's an example of how the layers system handles a complex interaction:

Initial card: Grizzly Bears (2/2 green creature - Bear)
Effect 1: Darksteel Mutation (becomes 0/1 artifact creature with no abilities)
Effect 2: +1/+1 counter
Effect 3: Painter's Servant (becomes blue)

The system processes this as:

  1. Layer 4 (Type): Now an artifact creature
  2. Layer 5 (Color): Now a blue artifact creature
  3. Layer 6 (Abilities): Now has no abilities
  4. Layer 7 (P/T): Base P/T becomes 0/1, then +1/+1 counter makes it 1/2

Final result: 1/2 blue artifact creature with no abilities.

Special Card Implementations

This document covers the implementation details of cards with unique and complex mechanics that require special handling in our engine.

Shahrazad

Shahrazad

Shahrazad is a unique sorcery that creates a subgame:

Shahrazad {W}{W}
Sorcery
Players play a Magic subgame, using their libraries as their decks. Each player who doesn't win the subgame loses half their life, rounded up.

Implementation Details

Shahrazad requires deep integration with the game engine to manage subgames:

#![allow(unused)]
fn main() {
#[derive(Component)]
pub struct Shahrazad;

impl CardEffect for Shahrazad {
    fn resolve(&self, world: &mut World, effect_ctx: &EffectContext) -> EffectResult {
        // Get access to necessary systems
        let mut subgame_system = world.resource_mut::<SubgameSystem>();
        
        // Start a subgame using the libraries as decks
        subgame_system.start_new_subgame(effect_ctx);
        
        // The subgame will run completely before continuing
        // Return pending to indicate this effect pauses resolution
        EffectResult::Pending
    }
}

// Called when the subgame completes
pub fn apply_shahrazad_results(
    mut commands: Commands,
    mut player_query: Query<(Entity, &mut Life), With<Player>>,
    subgame_result: Res<SubgameResult>,
) {
    // Apply life loss to players who didn't win
    for (player_entity, mut life) in player_query.iter_mut() {
        if !subgame_result.winners.contains(&player_entity) {
            // Lose half life, rounded up
            life.value = life.value - (life.value + 1) / 2;
        }
    }
}
}

Testing Approach

Testing Shahrazad is complex due to its nesting capabilities:

  1. Unit Testing: We verify the subgame initialization and cleanup logic
  2. Integration Testing: We test interactions between the subgame and main game
  3. End-to-End Testing: We validate full gameplay flows with subgames

Example test:

#![allow(unused)]
fn main() {
#[test]
fn test_shahrazad_life_loss() {
    let mut app = App::new();
    // Setup test environment...
    
    // Initial life totals
    let player1_life = 20;
    let player2_life = 20;
    
    // Cast Shahrazad
    // ... test code to cast the spell ...
    
    // Simulate subgame where player 1 wins
    app.world.resource_mut::<SubgameResult>().winners = vec![player1_entity];
    app.update(); // Triggers apply_shahrazad_results
    
    // Verify player 2 lost half their life
    let new_player2_life = app.world.get::<Life>(player2_entity).unwrap().value;
    assert_eq!(new_player2_life, 10); // 20 -> 10
}
}

Karn Liberated

Karn Liberated

Karn Liberated is a planeswalker with an ultimate ability that restarts the game:

Karn Liberated {7}
Legendary Planeswalker — Karn
Starting loyalty: 6

+4: Target player exiles a card from their hand.
−3: Exile target permanent.
−14: Restart the game, leaving in exile all non-Aura permanent cards exiled with Karn Liberated. Then put those cards onto the battlefield under your control.

Implementation Details

Karn's implementation focuses on tracking exiled cards and restarting the game:

#![allow(unused)]
fn main() {
#[derive(Component)]
pub struct KarnLiberated;

#[derive(Component)]
pub struct ExiledWithKarn;

impl CardEffect for KarnUltimate {
    fn resolve(&self, world: &mut World, effect_ctx: &EffectContext) -> EffectResult {
        // Verify loyalty cost can be paid
        if !self.can_pay_loyalty_cost(world, effect_ctx, -14) {
            return EffectResult::Failed;
        }
        
        // Pay loyalty cost
        self.pay_loyalty_cost(world, effect_ctx, -14);
        
        // Trigger game restart
        let mut restart_events = world.resource_mut::<Events<GameRestartEvent>>();
        restart_events.send(GameRestartEvent {
            source: effect_ctx.source_entity,
            restart_type: RestartType::KarnUltimate,
        });
        
        EffectResult::Success
    }
}

// System that runs when a game restart event occurs
pub fn handle_karn_restart(
    mut commands: Commands,
    mut restart_events: EventReader<GameRestartEvent>,
    exiled_cards: Query<(Entity, &CardData, &Owner), With<ExiledWithKarn>>,
    karn_controller: Query<&Controller, With<KarnLiberated>>,
) {
    for event in restart_events.read() {
        if event.restart_type != RestartType::KarnUltimate {
            continue;
        }
        
        // Get Karn's controller
        let karn_owner = karn_controller.get_single().unwrap();
        
        // Track cards that will be put onto the battlefield
        let cards_to_return = exiled_cards
            .iter()
            .filter(|(_, card_data, _)| !card_data.is_aura())
            .map(|(entity, _, _)| entity)
            .collect::<Vec<_>>();
        
        // Initialize new game state...
        
        // Put exiled cards onto the battlefield under Karn owner's control
        for card_entity in cards_to_return {
            // Set controller to Karn's controller
            commands.entity(card_entity)
                .insert(Controller(karn_owner.0))
                .remove::<ExiledWithKarn>();
                
            // Move to battlefield
            // ... zone transition code ...
        }
    }
}
}

Testing Approach

Testing Karn Liberated's restart ability requires comprehensive state verification:

  1. Unit Testing: We verify card tracking and the restart triggering
  2. Integration Testing: We ensure exiled cards correctly transfer to the new game
  3. End-to-End Testing: We validate the complete game restart flow

Example test:

#![allow(unused)]
fn main() {
#[test]
fn test_karn_ultimate_restart() {
    let mut app = App::new();
    // Setup test environment...
    
    // Create test cards and exile them with Karn
    let test_card1 = spawn_test_card(&mut app.world, "Test Card 1");
    let test_card2 = spawn_test_card(&mut app.world, "Test Card 2");
    
    // Add ExiledWithKarn to the cards
    app.world.entity_mut(test_card1).insert(ExiledWithKarn);
    app.world.entity_mut(test_card2).insert(ExiledWithKarn);
    
    // Activate Karn's ultimate
    // ... test code to activate the ability ...
    
    // Verify game restarted and cards are on the battlefield
    let card1_zone = app.world.get::<Zone>(test_card1).unwrap();
    assert_eq!(*card1_zone, Zone::Battlefield);
    
    let card1_controller = app.world.get::<Controller>(test_card1).unwrap();
    assert_eq!(card1_controller.0, karn_controller_id);
}
}

Other Special Cards

Mindslaver

Mindslaver allows a player to control another player's turn. Implementation involves intercepting player choices and redirecting control.

Panglacial Wurm

Panglacial Wurm can be cast while searching a library. Implementation requires special handling of the timing and visibility rules during search effects.

Time Stop

Time Stop ends the turn immediately. Implementation involves carefully cleaning up the stack and skipping directly to the cleanup step.

Integration with Game Engine

Special cards leverage several core engine systems:

For more on subgames and game restarting mechanics, see:

Card Rendering

Card rendering is responsible for the visual representation of Magic: The Gathering cards in Rummage. This system handles how cards are displayed in different zones, states, and views.

Rendering Philosophy

The card rendering system adheres to several key principles:

  • Accuracy: Cards should resemble their physical counterparts when appropriate
  • Readability: Card information should be easily readable at various scales
  • Performance: Rendering should be efficient, even with many cards on screen
  • Flexibility: The system should adapt to different screen sizes and device capabilities

Implementation

Card rendering is implemented using Bevy's rendering capabilities:

  • Entity-Component-System: Cards are entities with rendering components
  • Material system: Custom shaders for card effects
  • Texture atlases: Efficient texture management for card art and symbols
  • Text rendering: High-quality text rendering for card text

Card Layout

Cards are rendered with several key layout elements:

  • Frame: The card border and background
  • Art box: The card's artwork
  • Title bar: The card's name and mana cost
  • Type line: Card types, subtypes, and supertypes
  • Text box: Rules text and flavor text
  • Power/toughness box: For creatures
  • Loyalty counter: For planeswalkers
  • Set symbol: Indicating rarity and expansion

Art Assets

The rendering system uses various art assets:

  • Card artwork: The main illustration for each card
  • Frame templates: Card frames for different card types
  • Mana symbols: Symbols representing mana costs
  • Set symbols: Symbols for different expansions
  • UI elements: Additional interface elements for card interactions

Special Cases

The system handles special rendering cases such as:

  • Double-faced cards: Cards with two sides
  • Split cards: Cards with two halves
  • Adventure cards: Cards with an adventure component
  • Leveler cards: Cards with level-up abilities
  • Saga cards: Cards with chapter abilities
  • Planeswalkers: Cards with loyalty abilities

Dynamic Rendering

Cards dynamically adjust their rendering based on:

  • Zone: Different visual treatment in hand, battlefield, etc.
  • State: Tapped, attacking, blocking, etc.
  • Modifications: +1/+1 counters, auras attached, etc.
  • Focus: Card under mouse hover or selection

Art Assets

This document details the art assets used in the Rummage card rendering system. These assets are essential for creating visually accurate and appealing card representations.

Asset Categories

The rendering system uses several categories of art assets:

Card Artwork

  • Card illustrations: The main artwork for each card
  • Alternative art: Alternate versions of card artwork
  • Full-art treatments: Extended artwork for special cards
  • Borderless treatments: Artwork that extends to the card edges

Card Frames

  • Standard frames: Regular card frames for each card type
  • Special frames: Unique frames for special card sets
  • Showcase frames: Stylized frames for showcase cards
  • Promo frames: Special frames for promotional cards

Symbols

  • Mana symbols: Symbols representing different mana types
  • Set symbols: Symbols for each Magic: The Gathering set
  • Rarity indicators: Symbols indicating card rarity
  • Ability symbols: Symbols for keyword abilities (e.g., tap symbol)

UI Elements

  • Counter indicators: Visual elements for counters on cards
  • Status indicators: Indicators for card states (tapped, etc.)
  • Selection highlights: Visual feedback for selected cards
  • Targeting arrows: Visual elements for targeting

Asset Management

Asset Loading

Assets are loaded using Bevy's asset system:

#![allow(unused)]
fn main() {
// Example of loading card assets
fn load_card_assets(
    mut commands: Commands,
    asset_server: Res<AssetServer>,
    mut texture_atlases: ResMut<Assets<TextureAtlas>>,
) {
    // Load mana symbols texture atlas
    let mana_symbols_texture = asset_server.load("textures/mana_symbols.png");
    let mana_atlas = TextureAtlas::from_grid(
        mana_symbols_texture,
        Vec2::new(32.0, 32.0),
        6, 3,
        None,
        None
    );
    let mana_atlas_handle = texture_atlases.add(mana_atlas);
    
    // Store the handle for later use
    commands.insert_resource(ManaSymbolsAtlas(mana_atlas_handle));
    
    // Load card frame textures
    let standard_frame = asset_server.load("textures/frames/standard.png");
    commands.insert_resource(StandardFrame(standard_frame));
    
    // ... load other assets
}
}

Asset Optimization

The system optimizes asset usage through:

  • Texture atlases: Combining multiple small textures
  • Mipmap generation: For different viewing distances
  • Asset caching: Reusing assets across multiple cards
  • Lazy loading: Loading assets only when needed

Asset Creation Pipeline

Source Files

  • Vector graphics: SVG files for symbols and UI elements
  • High-resolution artwork: Source artwork files
  • Template designs: Base designs for card frames

Processing

  • Rasterization: Converting vector graphics to textures
  • Compression: Optimizing file sizes
  • Format conversion: Converting to engine-compatible formats
  • Atlas generation: Creating texture atlases

Special Asset Types

Animated Assets

  • Card animations: Special effects for certain cards
  • Transition effects: Animations for state changes
  • Foil effects: Simulating foil card appearance
  • Parallax effects: Depth effects for special cards

Procedural Assets

  • Dynamic frames: Frames generated based on card properties
  • Custom counters: Counters generated for specific cards
  • Adaptive backgrounds: Backgrounds that adapt to card colors

Asset Organization

Assets are organized in a structured directory hierarchy:

assets/
├── textures/
│   ├── card_art/
│   │   ├── set_code/
│   │   │   └── card_name.png
│   ├── frames/
│   │   ├── standard/
│   │   ├── special/
│   │   └── promo/
│   ├── symbols/
│   │   ├── mana/
│   │   ├── set/
│   │   └── ability/
│   └── ui/
│       ├── counters/
│       ├── indicators/
│       └── effects/
└── shaders/
    ├── card_effects/
    └── special_treatments/

Card Layout

This document details the layout system used for rendering Magic: The Gathering cards in Rummage. The layout system ensures cards are displayed with the correct proportions, elements, and visual style.

Card Dimensions

Standard Magic cards have specific dimensions that are maintained in the rendering system:

  • Physical card ratio: 2.5" × 3.5" (63mm × 88mm)
  • Digital aspect ratio: 5:7 (0.714)
  • Standard rendering size: Configurable based on UI scale

Layout Components

Cards are composed of several layout components:

Frame Components

  • Border: The outermost edge of the card
  • Frame: The colored frame indicating card type
  • Text box: Area containing rules text
  • Art box: Area containing the card's artwork
  • Title bar: Area containing the card's name and mana cost
  • Type line: Area containing the card's type information
  • Expansion symbol: Symbol indicating the card's set and rarity

Text Components

  • Name text: The card's name
  • Type text: The card's type line
  • Rules text: The card's rules text
  • Flavor text: The card's flavor text
  • Power/toughness: Creature's power and toughness
  • Loyalty: Planeswalker's loyalty

Special Components

  • Mana symbols: Symbols in the mana cost and rules text
  • Watermark: Background symbol in the text box
  • Foil pattern: Overlay for foil cards
  • Collector information: Card number, artist, copyright

Layout System

The layout system uses a combination of:

  • Relative positioning: Elements positioned relative to card dimensions
  • Grid system: Invisible grid for consistent alignment
  • Responsive scaling: Adjusting to different display sizes
  • Dynamic text flow: Text that reflows based on content

Implementation

The layout is implemented using Bevy's UI system:

#![allow(unused)]
fn main() {
// Example of creating a card layout
fn create_card_layout(
    commands: &mut Commands,
    card_entity: Entity,
    card_data: &CardData,
    assets: &CardAssets,
) {
    // Create the main card entity
    commands.entity(card_entity)
        .insert(Node::default())
        .insert(Style {
            size: Size::new(Val::Px(300.0), Val::Px(420.0)),
            ..Default::default()
        })
        .with_children(|parent| {
            // Add frame background
            parent.spawn(ImageBundle {
                style: Style {
                    size: Size::new(Val::Percent(100.0), Val::Percent(100.0)),
                    ..Default::default()
                },
                image: assets.get_frame_for_card(card_data).into(),
                ..Default::default()
            });
            
            // Add art box
            parent.spawn(ImageBundle {
                style: Style {
                    size: Size::new(Val::Percent(90.0), Val::Percent(40.0)),
                    margin: UiRect::new(
                        Val::Percent(5.0),
                        Val::Percent(5.0),
                        Val::Percent(15.0),
                        Val::Auto,
                    ),
                    ..Default::default()
                },
                image: assets.get_art_for_card(card_data).into(),
                ..Default::default()
            });
            
            // Add name text
            parent.spawn(TextBundle {
                style: Style {
                    position_type: PositionType::Absolute,
                    position: UiRect {
                        top: Val::Px(20.0),
                        left: Val::Px(20.0),
                        ..Default::default()
                    },
                    ..Default::default()
                },
                text: Text::from_section(
                    card_data.name.clone(),
                    TextStyle {
                        font: assets.card_name_font.clone(),
                        font_size: 18.0,
                        color: Color::BLACK,
                    },
                ),
                ..Default::default()
            });
            
            // Add other card elements...
        });
}
}

Special Card Layouts

The system supports various special card layouts:

Split Cards

  • Layout: Two half-cards joined along an edge
  • Orientation: Can be displayed horizontally or vertically
  • Text scaling: Smaller text to fit in half-size areas

Double-Faced Cards

  • Front face: Primary card face
  • Back face: Secondary card face
  • Transition: Smooth flip animation between faces

Adventure Cards

  • Main card: The creature portion
  • Adventure: The spell portion in a sub-frame

Saga Cards

  • Chapter markers: Visual indicators for saga chapters
  • Art layout: Extended art area
  • Chapter text: Text for each chapter ability

Text Rendering

Text rendering is handled with special care:

  • Font selection: Fonts that match the Magic card style
  • Text scaling: Automatic scaling based on text length
  • Symbol replacement: Replacing mana symbols in text
  • Text wrapping: Proper wrapping of rules text
  • Italics and emphasis: Styling for flavor text and reminders

Testing Cards

Card testing is a critical aspect of Rummage development, ensuring that cards function correctly according to Magic: The Gathering rules. This document outlines the approach and methodologies for testing cards in the system.

Testing Philosophy

The card testing system follows these principles:

  • Comprehensive coverage: All cards should be tested for correct behavior
  • Rule compliance: Cards must behave according to official MTG rules
  • Edge case handling: Tests should cover uncommon interactions
  • Regression prevention: Tests should catch regressions when code changes
  • Documentation: Tests serve as documentation for expected behavior

Testing Categories

Cards are tested across several categories:

Functional Testing

  • Basic functionality: Core card mechanics work as expected
  • Rules text adherence: Card behaves according to its rules text
  • Zone transitions: Card behaves correctly when moving between zones
  • State changes: Card responds correctly to changes in game state

Interaction Testing

  • Card-to-card interactions: How cards interact with other cards
  • Rules interactions: How cards interact with game rules
  • Timing interactions: How cards behave with timing-sensitive effects
  • Priority interactions: How cards interact with the priority system

Edge Case Testing

  • Corner cases: Unusual but valid game states
  • Rules exceptions: Cards that modify or break standard rules
  • Multiple effects: Cards with multiple simultaneous effects
  • Layer interactions: How cards behave with the layer system

Testing Tools

The testing system provides several tools:

  • Scenario builder: Create specific game states for testing
  • Action sequencer: Execute a series of game actions
  • State validator: Verify game state after actions
  • Snapshot comparison: Compare game states before and after effects
  • Rules oracle: Validate behavior against rules database

Example Test

Here's an example of a card test using the testing framework:

#![allow(unused)]
fn main() {
#[test]
fn lightning_bolt_deals_3_damage() {
    // Arrange: Set up the test scenario
    let mut test = TestScenario::new();
    let player1 = test.add_player(20);
    let player2 = test.add_player(20);
    let bolt = test.add_card_to_hand("Lightning Bolt", player1);
    
    // Act: Execute the actions
    test.play_card(bolt, Some(player2));
    test.resolve_top_of_stack();
    
    // Assert: Verify the outcome
    assert_eq!(test.get_player_life(player2), 17);
}
}

Testing Standards

All cards must pass these testing standards:

  • Functionality tests: Basic functionality works
  • Interaction tests: Works with related card types
  • Rules compliance: Follows comprehensive rules
  • Performance: Doesn't cause performance issues
  • Visual correctness: Renders correctly

Continuous Integration

Card tests are run as part of the continuous integration pipeline:

  • Tests run on every pull request
  • Cards with failed tests cannot be merged
  • Test coverage is tracked and reported

Effect Verification

This document outlines the methodologies and tools used to verify that card effects work correctly in Rummage. Effect verification is a critical part of ensuring that cards behave according to the Magic: The Gathering rules.

Verification Approach

Effect verification follows a systematic approach:

  1. Define expected behavior: Document how the effect should work
  2. Create test scenarios: Design scenarios that test the effect
  3. Execute tests: Run the tests and capture results
  4. Verify outcomes: Compare results to expected behavior
  5. Document edge cases: Record any special cases or interactions

Test Scenario Builder

The test scenario builder is a key tool for effect verification:

#![allow(unused)]
fn main() {
// Example of using the test scenario builder
#[test]
fn verify_lightning_bolt_effect() {
    // Create a test scenario
    let mut scenario = TestScenario::new();
    
    // Set up players
    let player1 = scenario.add_player(20);
    let player2 = scenario.add_player(20);
    
    // Add cards to the game
    let bolt = scenario.add_card_to_hand("Lightning Bolt", player1);
    
    // Execute game actions
    scenario.play_card(bolt, Some(player2));
    scenario.resolve_top_of_stack();
    
    // Verify the effect
    assert_eq!(scenario.get_player_life(player2), 17);
}
}

Effect Categories

Different effect categories require specific verification approaches:

Damage Effects

  • Direct damage: Verify damage is dealt to the correct target
  • Conditional damage: Verify conditions are correctly evaluated
  • Damage prevention: Verify prevention effects work correctly
  • Damage redirection: Verify damage is redirected properly

Card Movement Effects

  • Zone transitions: Verify cards move between zones correctly
  • Search effects: Verify search functionality works properly
  • Shuffle effects: Verify randomization is applied
  • Reordering effects: Verify cards are reordered correctly

Modification Effects

  • Stat changes: Verify power/toughness changes are applied
  • Ability changes: Verify abilities are added/removed correctly
  • Type changes: Verify type changes are applied correctly
  • Color changes: Verify color changes are applied correctly

Control Effects

  • Control changes: Verify control changes hands correctly
  • Duration: Verify control returns at the right time
  • Restrictions: Verify restrictions on controlled permanents

Verification Tools

Several tools assist in effect verification:

State Snapshots

Snapshots capture the game state before and after an effect:

#![allow(unused)]
fn main() {
// Example of using state snapshots
#[test]
fn verify_wrath_of_god_effect() {
    let mut scenario = TestScenario::new();
    
    // Set up game state with creatures
    let player1 = scenario.add_player(20);
    let wrath = scenario.add_card_to_hand("Wrath of God", player1);
    scenario.add_card_to_battlefield("Grizzly Bears", player1);
    scenario.add_card_to_battlefield("Serra Angel", player1);
    
    // Take a snapshot before the effect
    let before_snapshot = scenario.create_snapshot();
    
    // Execute the effect
    scenario.play_card(wrath, None);
    scenario.resolve_top_of_stack();
    
    // Take a snapshot after the effect
    let after_snapshot = scenario.create_snapshot();
    
    // Verify all creatures are destroyed
    assert_eq!(
        after_snapshot.count_permanents_by_type(player1, CardType::Creature),
        0
    );
    
    // Verify the difference between snapshots
    let diff = before_snapshot.diff(&after_snapshot);
    assert!(diff.contains(SnapshotDiff::Destroyed { 
        card_name: "Grizzly Bears".to_string() 
    }));
}
}

Event Trackers

Event trackers monitor events triggered during effect resolution:

#![allow(unused)]
fn main() {
// Example of using event trackers
#[test]
fn verify_lightning_helix_effect() {
    let mut scenario = TestScenario::new();
    
    // Set up the test
    let player1 = scenario.add_player(20);
    let player2 = scenario.add_player(20);
    let helix = scenario.add_card_to_hand("Lightning Helix", player1);
    
    // Create event trackers
    let mut damage_events = scenario.track_events::<DamageEvent>();
    let mut life_gain_events = scenario.track_events::<LifeGainEvent>();
    
    // Execute the effect
    scenario.play_card(helix, Some(player2));
    scenario.resolve_top_of_stack();
    
    // Verify damage event
    assert_eq!(damage_events.count(), 1);
    let damage_event = damage_events.last().unwrap();
    assert_eq!(damage_event.amount, 3);
    assert_eq!(damage_event.target, player2);
    
    // Verify life gain event
    assert_eq!(life_gain_events.count(), 1);
    let life_gain_event = life_gain_events.last().unwrap();
    assert_eq!(life_gain_event.amount, 3);
    assert_eq!(life_gain_event.player, player1);
}
}

Rules Oracle

The rules oracle verifies that effects comply with the MTG comprehensive rules:

#![allow(unused)]
fn main() {
// Example of using the rules oracle
#[test]
fn verify_protection_effect() {
    let mut scenario = TestScenario::new();
    
    // Set up the test
    let player1 = scenario.add_player(20);
    let player2 = scenario.add_player(20);
    
    let creature = scenario.add_card_to_battlefield("Grizzly Bears", player1);
    let protection = scenario.add_card_to_hand("Gods Willing", player1);
    let bolt = scenario.add_card_to_hand("Lightning Bolt", player2);
    
    // Give protection from red
    scenario.play_card(protection, Some(creature));
    scenario.choose_option("Red"); // Choose red for protection
    scenario.resolve_top_of_stack();
    
    // Try to bolt the protected creature
    scenario.play_card(bolt, Some(creature));
    
    // Verify with rules oracle
    let oracle_result = scenario.check_rules_compliance();
    assert!(oracle_result.has_rule_violation(RuleViolation::InvalidTarget));
}
}

Continuous Integration

Effect verification is integrated into the CI/CD pipeline:

  • Automated testing: All effect tests run on each commit
  • Regression detection: Changes that break effects are caught
  • Coverage tracking: Ensures all effects are tested
  • Performance monitoring: Tracks effect resolution performance

Interaction Testing

This document outlines the methodologies and tools used to test interactions between cards in Rummage. Interaction testing is crucial for ensuring that complex card combinations work correctly according to Magic: The Gathering rules.

Interaction Complexity

Card interactions in Magic: The Gathering can be extremely complex due to:

  • Layering system: Effects apply in a specific order
  • Timing rules: Effects happen at specific times
  • Replacement effects: Effects that replace other effects
  • Prevention effects: Effects that prevent other effects
  • Triggered abilities: Abilities that trigger based on events
  • State-based actions: Automatic game rules

Testing Approach

Interaction testing follows a systematic approach:

  1. Identify interaction pairs: Determine which cards interact
  2. Document expected behavior: Define how the interaction should work
  3. Create test scenarios: Design scenarios that test the interaction
  4. Execute tests: Run the tests and capture results
  5. Verify outcomes: Compare results to expected behavior

Interaction Test Framework

The interaction test framework provides tools for testing card interactions:

#![allow(unused)]
fn main() {
// Example of testing card interactions
#[test]
fn test_humility_and_opalescence_interaction() {
    let mut scenario = TestScenario::new();
    
    // Set up players
    let player = scenario.add_player(20);
    
    // Add the interacting cards
    let humility = scenario.add_card_to_battlefield("Humility", player);
    let opalescence = scenario.add_card_to_battlefield("Opalescence", player);
    
    // Add a test enchantment
    let test_enchantment = scenario.add_card_to_battlefield("Pacifism", player);
    
    // Verify the interaction effects
    
    // 1. Check that Humility is a 4/4 creature with no abilities
    let humility_card = scenario.get_permanent(humility);
    assert_eq!(humility_card.power, 4);
    assert_eq!(humility_card.toughness, 4);
    assert!(humility_card.has_no_abilities());
    
    // 2. Check that Opalescence is a 4/4 creature with no abilities
    let opalescence_card = scenario.get_permanent(opalescence);
    assert_eq!(opalescence_card.power, 4);
    assert_eq!(opalescence_card.toughness, 4);
    assert!(opalescence_card.has_no_abilities());
    
    // 3. Check that Pacifism is a 4/4 creature with no abilities
    let pacifism_card = scenario.get_permanent(test_enchantment);
    assert_eq!(pacifism_card.power, 4);
    assert_eq!(pacifism_card.toughness, 4);
    assert!(pacifism_card.has_no_abilities());
    
    // 4. Check that creatures on the battlefield are not affected by Pacifism
    let test_creature = scenario.add_card_to_battlefield("Grizzly Bears", player);
    let creature_card = scenario.get_permanent(test_creature);
    assert!(creature_card.can_attack());
}
}

Interaction Categories

Different categories of interactions require specific testing approaches:

Layer Interactions

Testing interactions between effects in different layers:

#![allow(unused)]
fn main() {
#[test]
fn test_layer_interactions() {
    let mut scenario = TestScenario::new();
    let player = scenario.add_player(20);
    
    // Layer 4 (Type) and Layer 7 (P/T) interaction
    let bear = scenario.add_card_to_battlefield("Grizzly Bears", player);
    let mutation = scenario.add_card_to_hand("Artificial Evolution", player);
    let giant_growth = scenario.add_card_to_hand("Giant Growth", player);
    
    // Change creature type (Layer 4)
    scenario.play_card(mutation, Some(bear));
    scenario.choose_text("Bear");
    scenario.choose_text("Elephant");
    scenario.resolve_top_of_stack();
    
    // Boost power/toughness (Layer 7)
    scenario.play_card(giant_growth, Some(bear));
    scenario.resolve_top_of_stack();
    
    // Verify the creature is now a 5/5 Elephant
    let bear_card = scenario.get_permanent(bear);
    assert_eq!(bear_card.power, 5);
    assert_eq!(bear_card.toughness, 5);
    assert!(bear_card.type_line.contains("Elephant"));
    assert!(!bear_card.type_line.contains("Bear"));
}
}

Timing Interactions

Testing interactions with specific timing requirements:

#![allow(unused)]
fn main() {
#[test]
fn test_timing_interactions() {
    let mut scenario = TestScenario::new();
    let player1 = scenario.add_player(20);
    let player2 = scenario.add_player(20);
    
    // Set up cards
    let creature = scenario.add_card_to_battlefield("Grizzly Bears", player1);
    let counterspell = scenario.add_card_to_hand("Counterspell", player2);
    let bolt = scenario.add_card_to_hand("Lightning Bolt", player1);
    
    // Player 1 casts Lightning Bolt
    scenario.play_card(bolt, Some(player2));
    
    // Player 2 responds with Counterspell
    scenario.pass_priority(player1);
    scenario.play_card(counterspell, Some(bolt));
    
    // Resolve the stack
    scenario.resolve_stack();
    
    // Verify Counterspell countered Lightning Bolt
    assert!(scenario.is_in_zone(bolt, Zone::Graveyard));
    assert!(scenario.is_in_zone(counterspell, Zone::Graveyard));
    assert_eq!(scenario.get_player_life(player2), 20); // Bolt was countered
}
}

Replacement Effect Interactions

Testing interactions between replacement effects:

#![allow(unused)]
fn main() {
#[test]
fn test_replacement_effect_interactions() {
    let mut scenario = TestScenario::new();
    let player = scenario.add_player(20);
    
    // Set up cards
    let creature = scenario.add_card_to_battlefield("Grizzly Bears", player);
    let prevention = scenario.add_card_to_battlefield("Circle of Protection: Red", player);
    let redirection = scenario.add_card_to_battlefield("Deflecting Palm", player);
    let bolt = scenario.add_card_to_hand("Lightning Bolt", player);
    
    // Activate Circle of Protection
    scenario.activate_ability(prevention, 0);
    scenario.pay_mana("{1}");
    scenario.resolve_top_of_stack();
    
    // Cast Lightning Bolt
    scenario.play_card(bolt, Some(player));
    
    // Choose which replacement effect to apply first
    scenario.choose_replacement_effect(redirection);
    
    // Resolve the stack
    scenario.resolve_stack();
    
    // Verify Deflecting Palm redirected the damage
    assert_eq!(scenario.get_player_life(player), 17); // Took 3 damage from redirection
}
}

Interaction Test Matrix

Complex interactions are organized in a test matrix:

  • Row cards: First card in the interaction
  • Column cards: Second card in the interaction
  • Cell tests: Tests for the specific interaction

This ensures comprehensive coverage of card interactions.

Automated Interaction Discovery

The system can automatically discover potential interactions:

#![allow(unused)]
fn main() {
// Example of automated interaction discovery
#[test]
fn discover_interactions() {
    let interaction_finder = InteractionFinder::new();
    
    // Find cards that interact with "Humility"
    let interactions = interaction_finder.find_interactions("Humility");
    
    // Verify known interactions are discovered
    assert!(interactions.contains("Opalescence"));
    assert!(interactions.contains("Nature's Revolt"));
    assert!(interactions.contains("Dryad Arbor"));
    
    // Generate tests for each interaction
    for interaction in interactions {
        let test = interaction_finder.generate_test("Humility", &interaction);
        test.run();
    }
}
}

Edge Case Testing

Interaction testing includes edge cases:

  • Multiple simultaneous effects: Many effects applying at once
  • Circular dependencies: Effects that depend on each other
  • Priority edge cases: Complex priority passing scenarios
  • Zone transition timing: Effects during zone transitions

Card Interaction Testing

This document explains the approach for testing card interactions in Rummage. It covers how to create robust tests for card effects, interactions between cards, and complex game scenarios.

Testing Framework

Rummage provides a flexible testing framework centered around the TestScenario struct, which simplifies setting up test environments for card interactions. This framework allows developers to:

  1. Create players with custom life totals
  2. Add cards to specific zones (hand, battlefield, graveyard, etc.)
  3. Play cards, including targeting
  4. Resolve the stack
  5. Verify game state after actions

Example Test

Here's a simple example of testing a Lightning Bolt:

#![allow(unused)]
fn main() {
#[test]
fn lightning_bolt_deals_3_damage() {
    let mut test = TestScenario::new();
    let player1 = test.add_player(20);
    let player2 = test.add_player(20);
    let bolt = test.add_card_to_hand("Lightning Bolt", player1);
    
    // Play Lightning Bolt targeting player2
    test.play_card(bolt, Some(player2));
    test.resolve_top_of_stack();
    
    // Verify player2 lost 3 life
    assert_eq!(test.get_player_life(player2), 17);
}
}

Testing Complex Interactions

For complex card interactions, the testing framework supports:

  1. Stack Interactions: Testing cards that interact with the stack, like counterspells
  2. Zone Transitions: Verifying cards move between zones correctly
  3. Targeting: Testing complex targeting requirements and restrictions
  4. State-Based Actions: Verifying state-based actions resolve correctly

Example of testing a counterspell:

#![allow(unused)]
fn main() {
#[test]
fn counterspell_counters_lightning_bolt() {
    let mut test = TestScenario::new();
    let player1 = test.add_player(20);
    let player2 = test.add_player(20);
    
    let bolt = test.add_card_to_hand("Lightning Bolt", player1);
    let counterspell = test.add_card_to_hand("Counterspell", player2);
    
    // Play Lightning Bolt targeting player2
    test.play_card(bolt, Some(player2));
    
    // In response, player2 casts Counterspell targeting Lightning Bolt
    test.play_card(counterspell, Some(bolt));
    
    // Resolve the stack from top to bottom
    test.resolve_top_of_stack(); // Counterspell resolves
    test.resolve_top_of_stack(); // Lightning Bolt tries to resolve but was countered
    
    // Verify player2 still has 20 life (Lightning Bolt was countered)
    assert_eq!(test.get_player_life(player2), 20);
}
}

Best Practices

When writing tests for card interactions, follow these best practices:

  1. Test the rule, not the implementation: Focus on testing the expected behavior according to MTG rules, not implementation details
  2. Cover edge cases: Test unusual interactions and edge cases
  3. Test interactions with different card types: Ensure cards interact correctly with different types (creatures, instants, etc.)
  4. Use realistic scenarios: Create test scenarios that mimic real game situations
  5. Document test intent: Clearly document what each test is verifying

Extending the Testing Framework

The testing framework is designed to be extensible. To add support for testing new card mechanics:

  1. Add new methods to the TestScenario struct
  2. Implement simulation of the new mechanic
  3. Add verification methods to check the result

Future Enhancements

The testing framework is still under development. Planned enhancements include:

  1. Support for more complex targeting scenarios
  2. Better simulation of priority and the stack
  3. Support for testing multiplayer interactions
  4. Integration with snapshot testing

UI Integration

This document outlines how the card systems integrate with the user interface in Rummage, ensuring a seamless and intuitive player experience.

Integration Architecture

The card systems and UI are integrated through a well-defined interface:

  • Card representations: Visual representations of cards
  • Interaction handlers: Systems that handle UI interactions with cards
  • Event propagation: Bidirectional event flow between UI and game logic
  • State synchronization: Keeping UI and game state in sync

Card Visualization

Cards are visualized in the UI through:

  • Card renderer: Renders cards based on their properties
  • State indicators: Visual indicators for card states (tapped, summoning sick, etc.)
  • Counter visualization: Display of counters on cards
  • Effect visualization: Visual effects for active abilities

Card Interactions

The UI supports various card interactions:

  • Dragging cards: Moving cards between zones
  • Clicking cards: Selecting or activating cards
  • Hovering cards: Displaying detailed information
  • Targeting: Selecting targets for spells and abilities
  • Stacking: Managing stacks of cards in zones

Implementation Details

The integration uses Bevy's entity component system:

#![allow(unused)]
fn main() {
// Example of a system that handles card selection in the UI
fn handle_card_selection(
    mouse_input: Res<Input<MouseButton>>,
    mut selected_card: ResMut<SelectedCard>,
    card_query: Query<(Entity, &GlobalTransform, &Card, &Interaction)>,
) {
    if mouse_input.just_pressed(MouseButton::Left) {
        for (entity, transform, card, interaction) in card_query.iter() {
            if matches!(interaction, Interaction::Clicked) {
                selected_card.entity = Some(entity);
                // Trigger UI update for selection
            }
        }
    }
}
}

Zone Representation

Different zones are represented in the UI:

  • Hand: Cards arranged in a fan or row
  • Battlefield: Grid-based layout of permanent cards
  • Graveyard: Stack of cards with access to view
  • Exile: Similar to graveyard but visually distinct
  • Stack: Visual representation of spells and abilities waiting to resolve

Responsiveness

The UI adapts to different situations:

  • Different screen sizes: Layouts adjust to available space
  • Varying card counts: Handles different numbers of cards in zones
  • Game state changes: Updates in response to game state changes

Accessibility Considerations

The integration includes accessibility features:

  • Color blindness support: Uses patterns and symbols in addition to colors
  • Screen reader compatibility: Cards and states have text descriptions
  • Keyboard navigation: Full functionality without mouse
  • Customizable settings: Adjustable text size, contrast, etc.

Performance Optimization

The UI integration is optimized for performance:

  • Culling: Only render visible cards
  • Level of detail: Simplified representations for distant or small cards
  • Asset management: Efficient loading and unloading of card assets
  • Batched rendering: Group similar cards for efficient rendering

Game UI System Documentation

This section provides a comprehensive overview of the in-game user interface systems for Rummage's Commander format Magic: The Gathering implementation.

Table of Contents

  1. Overview
  2. Key Components
  3. Implementation Status
  4. Integration with Card Systems
  5. Integration with Networking

Overview

The Game UI system is responsible for rendering the game state, facilitating player interactions, and providing visual feedback. Built using Bevy's Entity Component System (ECS) architecture, the UI serves as the visual representation layer that connects the player to the underlying game mechanics.

Our UI design philosophy focuses on three key principles:

  1. Clarity: Making game state clearly visible and understandable
  2. Usability: Providing intuitive interactions for complex game mechanics
  3. Immersion: Creating a visually appealing and thematically consistent experience

The UI system functions as a bridge between the player and the game engine, translating ECS component data into visual elements and user inputs into game actions. It maintains its own set of UI-specific entities and components that represent the visual state of the game, which are updated in response to changes in the core game state.

For a more detailed overview, see the overview document.

Key Components

The Game UI system consists of the following major components:

  1. Layout Components

    • Playmat
    • Command Zone
    • Battlefield
    • Player Zones
    • Stack Visualization
  2. Card Visualization

    • Card Rendering
    • Card States (Tapped, Exiled, etc.)
    • Card Animations
    • State Transitions
  3. Interaction Systems

    • Card Selection
    • Drag and Drop
    • Action Menus
    • Targeting System
    • Input Validation
  4. Information Display

    • Game Log
    • Phase Indicators
    • Priority Visualization
    • Tooltips and Helpers
    • Rules References
  5. Game Flow

    • Turn Visualization
    • Phase Transitions
    • Priority Passing
    • Timer Indicators
  6. Special UI Elements

    • Modal Dialogs
    • Choice Interfaces
    • Decision Points
    • Triggered Ability Selections
  7. Multiplayer Considerations

    • Player Positioning
    • Visibility Controls
    • Opponent Actions
    • Synchronization Indicators
  8. Table View

    • Battlefield Layout
    • Card Stacking
    • Zone Visualization
    • Spatial Management
  9. Playmat Design

    • Background Design
    • Zone Demarcation
    • Visual Themes
    • Customization Options
  10. Chat System

    • Message Display
    • Input Interface
    • Emotes
    • Communication Filters
  11. Avatar System

    • Player Avatars
    • Avatar Selection
    • Custom Avatar Support
    • Visual Feedback
  12. Testing

    • Unit Testing UI Components
    • Integration Testing
    • UI Automation Testing
    • Visual Regression Testing

Implementation Status

This documentation represents the design and implementation of the Game UI system. Components are marked as follows:

ComponentStatusDescription
Core UI FrameworkBasic UI rendering and interaction system
Card VisualizationRendering cards and their states
Battlefield LayoutArrangement of permanents on the battlefield
Hand InterfacePlayer's hand visualization and interaction
Stack Visualization🔄Visual representation of the spell stack
Command Zone🔄Interface for commanders and command zone abilities
Phase/Turn Indicators🔄Visual indicators for game phases and turns
Player InformationDisplay of player life, mana, and other stats
Targeting System🔄System for selecting targets for spells and abilities
Decision Interfaces⚠️Interfaces for player decisions and choices
Chat System⚠️In-game communication system
Settings Menu⚠️Interface for adjusting game settings

Legend:

  • ✅ Implemented and tested
  • 🔄 In progress
  • ⚠️ Planned but not yet implemented

Integration with Card Systems

The Game UI system works in close collaboration with the Card Systems to transform the core game data into interactive visual elements:

Visualization Pipeline

The UI receives card data from the Card Systems and renders it according to the current game state:

  • Card entities from the game engine are mapped to visual representations
  • The UI listens for changes to card components (like tapped status or counters)
  • Visual effects are applied based on card state changes

Interaction Translation

User interactions with card visuals are translated back into game engine commands:

  • Dragging a card triggers zone transfer requests in the game engine
  • Clicking on cards selects them for targeting or activation
  • Right-clicking shows context-sensitive actions relevant to the card

Special Card Rendering

Some cards require special UI handling:

  • Modal cards present choice interfaces
  • Split cards have multiple faces to visualize
  • Transformed cards need to show both states
  • Cards with counters display them visually

For detailed information on how cards are represented in the data model, see the Card Database documentation.

Integration with Networking

In multiplayer games, the UI system coordinates with the Networking module to ensure consistent visual representation across all clients:

State Synchronization

The UI responds to network synchronization events:

  • Updates card positions and states based on received snapshots
  • Provides visual indicators for network operations (opponent thinking, sync status)
  • Handles delayed or out-of-order updates gracefully

Action Broadcasting

When a player performs an action, the UI:

  1. Shows a local preview of the expected result
  2. Sends the action to the server via the networking system
  3. Updates the display when confirmation is received
  4. Handles conflicts if server state differs from prediction

Latency Compensation

To provide a responsive feel despite network latency:

  • The UI implements client-side prediction for common actions
  • Visual feedback indicates when actions are pending confirmation
  • Animated transitions smooth out state updates from the server

For more detailed information on how the UI integrates with networked gameplay, see the Gameplay Networking documentation.


For detailed information on specific UI components, please refer to the respective sections listed above.

Game UI System Overview

This document provides a high-level overview of the user interface system for Rummage's Commander format game. For more detailed information on specific components, please see the related documentation files.

UI Architecture Overview

The Rummage game UI is built using Bevy's Entity Component System (ECS) architecture, with a clear separation of concerns between game logic and visual presentation. The UI follows a layered approach to ensure clean organization, efficient rendering, and maintainable code.

Key Components

The UI system uses non-deprecated Bevy 0.15.x components:

  • Node for layout containers (replacing deprecated NodeBundle)
  • Text2d for text display (replacing deprecated Text2dBundle)
  • Sprite for images (replacing deprecated SpriteBundle)
  • Button for interactive elements
  • RenderLayers for visibility control

Layers

The UI architecture is organized into distinct render layers as defined in src/camera/components.rs:

#![allow(unused)]
fn main() {
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
pub enum AppLayer {
    #[default]
    Shared,         // Layer 0: Shared elements visible to all cameras
    Background,     // Layer 1: Game background elements
    GameWorld,      // Layer 2: Game world elements
    Cards,          // Layer 3: Card entities
    GameUI,         // Layer 4: In-game UI elements
    Effects,        // Layer 5: Visual effects
    Overlay,        // Layer 6: Game overlays
    Menu,           // Layer 7: Menu elements
    Popup,          // Layer 8: Popup dialogs
    Debug,          // Layer 9: Debug visuals
    DebugText,      // Layer 10: Debug text
    DebugGizmo,     // Layer 11: Debug gizmos
    Wireframe,      // Layer 12: Wireframe visualization
    Game = 31,      // Layer 31: Legacy game layer (backward compatibility)
}
}

These layers enable precise control over which UI elements are visible in different game states.

Design Philosophy

The Rummage UI design follows these key principles:

  1. Clarity First: Game state information must be clear and unambiguous
  2. Accessibility: UI should be usable by players with diverse accessibility needs
  3. Intuitive Interaction: Similar to physical Magic, but enhanced by digital capabilities
  4. Visual Hierarchy: Important elements stand out through size, color, and animation
  5. Responsive Design: Adapts to different screen sizes and orientations
  6. Performance: Optimized rendering for smooth gameplay

Core UI Systems

1. Layout Management

The game employs a flexible layout system that organizes UI elements into zones:

  • Player Zones: Hand, battlefield, graveyard, exile, library positions
  • Shared Zones: Stack, command zone, and turn structure indicators
  • Information Zones: Game log, phase indicators, and supplementary information

Layouts adjust dynamically based on player count, screen size, and game state.

2. Interaction System

The interaction system handles:

  • Drag and Drop: Moving cards between zones, with preview of valid targets
  • Targeting: Selecting targets for spells and abilities
  • Context Menus: Right-click (or long press) for additional card actions
  • Hotkeys: Keyboard shortcuts for common actions

3. State Visualization

The UI visualizes game state through:

  • Card Transformations: Visual representation of card states (tapped, attacking, etc.)
  • Animations: Visual feedback for game events and transitions
  • Effects: Particles and visual flourishes for special events

4. Information Display

Information is conveyed through:

  • Text: Game text, rules text, and reminders
  • Icons: Visual representation of card types, abilities, and states
  • Tooltips: Contextual help and explanations
  • Game Log: Record of game events and actions

Integration with Game Logic

The UI system integrates with the game logic through:

  • Systems: Reacting to game state changes and updating visuals
  • Events: Processing user interactions and converting them to game actions
  • Resources: Accessing shared game state to reflect in the UI

Technical Implementation

The UI system is implemented using Bevy's ECS pattern:

  • Components: Define UI element properties and behaviors
  • Systems: Update and render UI elements based on game state
  • Resources: Store shared UI state information
  • Events: Handle user input and trigger UI updates

Accessibility Features

The game includes several accessibility features:

  • Color Blind Modes: Alternative color schemes for color-blind players
  • Text Scaling: Adjustable text size for readability
  • Screen Reader Support: Critical game information is accessible via screen readers
  • Keyboard Controls: Full game functionality available through keyboard
  • Animation Reduction: Option to reduce or disable animations

Performance Considerations

The UI system is optimized for performance through:

  • Element Pooling: Reusing UI elements to reduce allocation
  • Batched Rendering: Minimizing draw calls
  • Culling: Only rendering visible elements
  • Async Loading: Loading assets in the background

Future Enhancements

Planned enhancements to the UI system include:

  • Custom Themes: Player-selectable UI themes
  • UI Animations: Enhanced visual feedback
  • Mobile Optimization: Touch-specific controls and layouts
  • VR Mode: Virtual reality support for immersive gameplay

Game UI Layout Components

This document describes the layout components used in the Rummage game interface, focusing on the spatial organization and structure of the UI elements.

Table of Contents

  1. Playmat
  2. Command Zone
  3. Battlefield
  4. Player Zones
  5. Stack Visualization
  6. Turn Structure UI

Layout Philosophy

The Rummage game layout follows these principles:

  1. Spatial Clarity: Each game zone has a distinct location
  2. Player Symmetry: Player areas are arranged consistently
  3. Focus on Active Elements: Important game elements receive visual prominence
  4. Efficient Screen Usage: Maximize the viewing area for the battlefield
  5. Logical Grouping: Related UI elements are positioned near each other

Layout Overview

The game UI is structured around a central battlefield with player zones positioned around it:

┌─────────────────────────────────────────────────────────────────┐
│                     OPPONENT INFORMATION                         │
├───────────┬─────────────────────────────────────┬───────────────┤
│           │                                     │               │
│ OPPONENT  │                                     │   OPPONENT    │
│ EXILE     │                                     │   GRAVEYARD   │
│           │                                     │               │
├───────────┤                                     ├───────────────┤
│           │                                     │               │
│           │                                     │               │
│ OPPONENT  │                                     │   STACK       │
│ LIBRARY   │          BATTLEFIELD                │   AREA        │
│           │                                     │               │
│           │                                     │               │
├───────────┤                                     ├───────────────┤
│           │                                     │               │
│ PLAYER    │                                     │   PLAYER      │
│ LIBRARY   │                                     │   GRAVEYARD   │
│           │                                     │               │
├───────────┼─────────────────────────────────────┼───────────────┤
│           │           PLAYER HAND               │   COMMAND     │
│  PLAYER   ├─────────────────────────────────────┤   ZONE        │
│  EXILE    │         PLAYER INFORMATION          │               │
└───────────┴─────────────────────────────────────┴───────────────┘

This layout adjusts for multiplayer games, positioning all opponents around the battlefield.

Responsive Adaptations

The layout adapts to different screen sizes and orientations:

Desktop Layout

  • Horizontal layout with wide battlefield
  • Hand cards displayed horizontally
  • Full information displays

Tablet Layout

  • Similar to desktop with slightly compressed zones
  • Touch-friendly spacing
  • Collapsible information panels

Mobile Layout

  • Vertical orientation with stacked zones
  • Scrollable battlefield
  • Collapsible hand and information displays
  • Gesture-based zone navigation

Dynamic Player Positioning

For multiplayer games (3-4 players), the layout adjusts to position player zones around the battlefield:

Four Player Layout:

┌───────────┬─────────────────────────────┬───────────┐
│           │      OPPONENT 2             │           │
│ OPPONENT2 │  ┌───────────────────────┐  │ OPPONENT2 │
│ ZONES     │  │                       │  │ ZONES     │
├───────────┤  │                       │  ├───────────┤
│           │  │                       │  │           │
│ OPPONENT1 │  │                       │  │ OPPONENT3 │
│ ZONES     │  │      BATTLEFIELD      │  │ ZONES     │
│           │  │                       │  │           │
│           │  │                       │  │           │
│           │  │                       │  │           │
├───────────┤  │                       │  ├───────────┤
│ COMMAND   │  └───────────────────────┘  │   STACK   │
│ ZONE      │       PLAYER ZONES          │   AREA    │
└───────────┴─────────────────────────────┴───────────┘

Zone Transitions

Zones provide visual cues during transitions:

  • Highlight: Active zones highlight during player turns
  • Animation: Cards animate when moving between zones
  • Focusing: Relevant zones enlarge when cards within them are targeted or selected

Implementation Details

Layout components are implemented using Bevy's UI system with nested Node components:

#![allow(unused)]
fn main() {
// Example layout container for a player's battlefield area
commands
    .spawn((
        Node {
            width: Val::Percent(100.0),
            height: Val::Percent(40.0),
            flex_direction: FlexDirection::Column,
            align_items: AlignItems::Center,
            justify_content: JustifyContent::Center,
            ..default()
        },
        BattlefieldContainer,
        AppLayer::GameUI.layer(),
    ))
    .with_children(|parent| {
        // Individual battlefield row containers
        // ...
    });
}

Layout Managers

Layout position is managed by dedicated systems that:

  1. Calculate zone positions based on player count and screen size
  2. Adjust UI element scale to maintain usability
  3. Reposition elements in response to game state changes
  4. Handle element focus and highlighting

The primary layout management systems include:

  • update_layout_for_player_count: Adjusts layout based on number of players
  • update_responsive_layout: Adapts layout to screen size changes
  • position_cards_in_zones: Manages card positioning within zones

Each layout component has dedicated documentation explaining its specific implementation.

Layout System

The Layout System is responsible for organizing and positioning UI elements in the Rummage game interface. It provides a flexible foundation for arranging game elements across different screen sizes and orientations.

Core Architecture

The layout system is built on Bevy's native UI components, using a component-based approach:

#![allow(unused)]
fn main() {
#[derive(Component)]
pub struct LayoutContainer {
    pub container_type: ContainerType,
    pub flex_direction: FlexDirection,
    pub size: Vec2,
    pub padding: UiRect,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ContainerType {
    Root,
    Player,
    Battlefield,
    Hand,
    Stack,
    CommandZone,
    InfoPanel,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FlexDirection {
    Row,
    Column,
    RowReverse,
    ColumnReverse,
}
}

Hierarchy Structure

The UI is organized in a hierarchical structure:

  1. Root Container: The main layout container that spans the entire screen
  2. Zone Containers: Containers for each game zone (battlefield, hand, graveyard, etc.)
  3. Element Containers: Containers for specific UI elements (cards, controls, info panels)
  4. Individual Elements: The actual UI components that players interact with

Layout Calculation

Layout positions are calculated using a combination of:

  • Absolute Positioning: Fixed positions for certain UI elements
  • Flex Layout: Flexible positioning based on container size and available space
  • Grid Layout: Grid-based positioning for elements like cards on the battlefield
  • Anchoring: Elements can be anchored to edges or centers of their containers
#![allow(unused)]
fn main() {
fn calculate_layout(
    mut query: Query<(&mut Transform, &LayoutElement, &Parent)>,
    containers: Query<&LayoutContainer>,
) {
    // Implementation details for calculating positions
}
}

Responsive Design

The layout system adapts to different screen sizes and aspect ratios:

  • Breakpoints: Different layouts are used at specific screen size breakpoints
  • Scale Factors: UI elements scale based on screen resolution
  • Prioritization: Critical elements are prioritized in limited space
  • Overflow Handling: Scrolling or pagination for overflow content
#![allow(unused)]
fn main() {
fn adapt_to_screen_size(
    mut layout_query: Query<&mut LayoutContainer>,
    windows: Res<Windows>,
) {
    // Implementation details for screen adaptation
}
}

Zone-specific Layouts

Each game zone has specialized layout behavior:

Battlefield Layout

  • Grid-based layout for permanents
  • Automatic card grouping by type
  • Dynamic spacing based on card count
  • Support for attacking/blocking visualization

Hand Layout

  • Fan or straight-line layouts
  • Automatic card reorganization
  • Vertical position adjustment during card selection
  • Overflow handling for large hands

Stack Layout

  • Cascading layout for spells and abilities
  • Visual nesting for complex interactions
  • Animation support for stack resolution

Integration with Card Visualization

The layout system works closely with the Card Visualization system:

  • Positions cards according to their game state
  • Handles z-ordering for overlapping cards
  • Provides layout input for card animations
  • Adjusts spacing based on card visibility needs

Plugin Integration

The layout system is implemented as a Bevy plugin:

#![allow(unused)]
fn main() {
pub struct LayoutPlugin;

impl Plugin for LayoutPlugin {
    fn build(&self, app: &mut App) {
        app
            .add_systems(Startup, setup_layout)
            .add_systems(
                Update,
                (
                    calculate_layout,
                    adapt_to_screen_size,
                    handle_layout_changes,
                ),
            );
    }
}
}

Performance Considerations

The layout system is optimized for performance:

  • Batched layout calculations to minimize CPU usage
  • Layout caching to prevent unnecessary recalculations
  • Hierarchical dirty flagging to only update changed layouts
  • Custom layout algorithms for common patterns

Example: Battlefield Layout

#![allow(unused)]
fn main() {
fn create_battlefield_layout(commands: &mut Commands, ui_materials: &UiMaterials) {
    commands
        .spawn((
            SpatialBundle::default(),
            LayoutContainer {
                container_type: ContainerType::Battlefield,
                flex_direction: FlexDirection::Column,
                size: Vec2::new(1600.0, 900.0),
                padding: UiRect::all(Val::Px(10.0)),
            },
            Name::new("Battlefield Container"),
        ))
        .with_children(|parent| {
            // Create player areas within battlefield
            for player_index in 0..4 {
                create_player_battlefield_area(parent, ui_materials, player_index);
            }
            
            // Create center area for shared elements
            create_center_battlefield_area(parent, ui_materials);
        });
}
}

Customization

The layout system supports customization through:

  • Layout Themes: Pre-defined layout configurations
  • User Preferences: User-adjustable layout options
  • Dynamic Adaptation: Runtime layout adjustments based on game state

For more details on how layouts integrate with other UI systems, see Zone Visualization and Responsive Design.

Responsive Design

This document describes how the Rummage game UI adapts to different screen sizes, aspect ratios, and device types to provide an optimal user experience across platforms.

Responsive Architecture

The responsive design system is built on several key components:

  • Breakpoint System: Defines screen size thresholds for layout changes
  • Flexible Layout: Uses relative sizing and positioning
  • Dynamic Scaling: Adjusts UI element sizes based on screen dimensions
  • Priority-based Visibility: Shows/hides elements based on available space
  • Orientation Handling: Adapts to landscape and portrait orientations

Breakpoint System

The UI defines several breakpoints that trigger layout changes:

#![allow(unused)]
fn main() {
pub enum ScreenBreakpoint {
    Mobile,      // < 768px width
    Tablet,      // 768px - 1279px width
    Desktop,     // 1280px - 1919px width
    LargeDesktop // >= 1920px width
}
}

Each breakpoint has associated layout configurations that adjust:

  • Element sizes and positions
  • Information density
  • Touch target sizes
  • Visibility of secondary elements

Layout Adaptation

Desktop Layout

On larger screens, the UI maximizes information visibility:

  • Full battlefield view with minimal scrolling
  • Expanded card hand display
  • Detailed player information panels
  • Side-by-side arrangement of game zones
#![allow(unused)]
fn main() {
fn configure_desktop_layout(
    mut layout_query: Query<&mut LayoutContainer, With<RootContainer>>,
) {
    for mut container in layout_query.iter_mut() {
        container.flex_direction = FlexDirection::Row;
        container.size = Vec2::new(1920.0, 1080.0);
        // Additional desktop-specific configuration
    }
}
}

Tablet Layout

On medium-sized screens, the UI balances information and usability:

  • Slightly compressed game zones
  • Collapsible information panels
  • Touch-friendly spacing and controls
  • Optional side panels that can be toggled

Mobile Layout

On small screens, the UI prioritizes core gameplay elements:

  • Vertically stacked layout
  • Swipeable/scrollable zones
  • Collapsible hand that expands on tap
  • Minimized information displays with expandable details
  • Larger touch targets for better usability
#![allow(unused)]
fn main() {
fn configure_mobile_layout(
    mut layout_query: Query<&mut LayoutContainer, With<RootContainer>>,
) {
    for mut container in layout_query.iter_mut() {
        container.flex_direction = FlexDirection::Column;
        container.size = Vec2::new(390.0, 844.0); // iPhone 12 Pro dimensions as example
        // Additional mobile-specific configuration
    }
}
}

Dynamic Element Scaling

UI elements scale proportionally based on screen size:

  • Cards maintain aspect ratio while adjusting overall size
  • Text scales to remain readable on all devices
  • Touch targets maintain minimum size for usability
  • Spacing adjusts to prevent crowding on small screens
#![allow(unused)]
fn main() {
fn scale_ui_elements(
    mut card_query: Query<&mut Transform, With<CardVisual>>,
    screen_size: Res<ScreenDimensions>,
) {
    let scale_factor = calculate_scale_factor(screen_size.width, screen_size.height);
    
    for mut transform in card_query.iter_mut() {
        transform.scale = Vec3::splat(scale_factor);
    }
}
}

Orientation Handling

The UI adapts to device orientation changes:

Landscape Orientation

  • Horizontal layout with wide battlefield
  • Hand displayed along bottom edge
  • Player information in corners

Portrait Orientation

  • Vertical layout with stacked elements
  • Hand displayed along bottom edge
  • Battlefield takes central position
  • Player information at top and bottom
#![allow(unused)]
fn main() {
fn handle_orientation_change(
    mut orientation_events: EventReader<OrientationChangedEvent>,
    mut layout_query: Query<&mut LayoutContainer, With<RootContainer>>,
) {
    for event in orientation_events.read() {
        match event.orientation {
            Orientation::Landscape => configure_landscape_layout(&mut layout_query),
            Orientation::Portrait => configure_portrait_layout(&mut layout_query),
        }
    }
}
}

Priority-based Element Visibility

When screen space is limited, elements are shown or hidden based on priority:

  1. Essential: Always visible (battlefield, hand, stack)
  2. Important: Visible when space allows (player info, graveyards)
  3. Secondary: Collapsed by default, expandable on demand (exile, detailed card info)
  4. Optional: Hidden on small screens (decorative elements, full game log)

Implementation

The responsive design system is implemented through several components:

#![allow(unused)]
fn main() {
// Tracks current screen dimensions and orientation
#[derive(Resource)]
pub struct ScreenDimensions {
    pub width: f32,
    pub height: f32,
    pub orientation: Orientation,
    pub breakpoint: ScreenBreakpoint,
}

// System to detect screen changes
fn detect_screen_changes(
    mut screen_dimensions: ResMut<ScreenDimensions>,
    windows: Res<Windows>,
    mut orientation_events: EventWriter<OrientationChangedEvent>,
) {
    // Implementation details
}
}

Testing

The responsive design is tested across multiple device profiles:

  • Desktop monitors (various resolutions)
  • Tablets (iPad, Android tablets)
  • Mobile phones (iOS, Android)
  • Ultrawide monitors
  • Small laptops

Integration

The responsive design system integrates with:

For more details on specific UI components and how they adapt, see the relevant component documentation.

Zone Visualization

This document describes how different game zones are visually represented in the Rummage UI.

Zone Types

Magic: The Gathering defines several game zones, each with unique visualization requirements:

  • Battlefield: Where permanents are played
  • Hand: Cards held by a player
  • Library: A player's deck
  • Graveyard: Discard pile
  • Exile: Cards removed from the game
  • Stack: Spells and abilities waiting to resolve
  • Command Zone: Special zone for commanders and emblems

Visual Representation

Each zone has a distinct visual style to help players quickly identify it:

Battlefield

  • Layout: Grid-based layout with flexible positioning
  • Background: Textured playmat appearance
  • Borders: Subtle zone boundaries for each player's area
  • Organization: Cards automatically group by type (creatures, lands, etc.)
#![allow(unused)]
fn main() {
fn create_battlefield_zone(commands: &mut Commands, materials: &UiMaterials) {
    commands.spawn((
        SpatialBundle::default(),
        Node {
            background_color: materials.battlefield_background.clone(),
            ..default()
        },
        BattlefieldZone,
        Name::new("Battlefield Zone"),
    ));
}
}

Hand

  • Layout: Fan or straight-line arrangement
  • Background: Semi-transparent panel
  • Privacy: Only visible to the controlling player (except in spectator mode)
  • Interaction: Cards rise when hovered

Library

  • Visualization: Stack of cards showing only the back
  • Counter: Numerical display of remaining cards
  • Animation: Cards visibly draw from top

Graveyard

  • Layout: Stacked with slight offset to show multiple cards
  • Visibility: Top card always visible
  • Interaction: Can be expanded to view all cards

Exile

  • Visual Style: Distinct "removed from game" appearance
  • Organization: Grouped by source when relevant
  • Special Effects: Subtle visual effects to distinguish from other zones

Stack

  • Layout: Cascading arrangement showing pending spells/abilities
  • Order: Clear visual indication of resolution order
  • Targeting: Visual connections to targets
  • Animation: Items move as they resolve

Command Zone

  • Prominence: Visually distinct and always accessible
  • Commander Display: Shows commander card(s)
  • Tax Counter: Visual indicator of commander tax

Zone Transitions

When cards move between zones, the transition is animated to provide visual feedback:

#![allow(unused)]
fn main() {
fn animate_zone_transition(
    commands: &mut Commands,
    card_entity: Entity,
    from_zone: Zone,
    to_zone: Zone,
    animation_assets: &AnimationAssets,
) {
    // Calculate start and end positions
    let start_pos = get_zone_position(from_zone);
    let end_pos = get_zone_position(to_zone);
    
    // Create animation sequence
    commands.entity(card_entity).insert(AnimationSequence {
        animations: vec![
            Animation::new_position(start_pos, end_pos, Duration::from_millis(300)),
            Animation::new_scale(Vec3::splat(0.9), Vec3::ONE, Duration::from_millis(150)),
        ],
        on_complete: Some(ZoneTransitionComplete { zone: to_zone }),
    });
}
}

Zone Interaction

Zones support various interactions:

  • Click: Select the zone or expand it for more detail
  • Drag-to: Move cards to the zone
  • Drag-from: Take cards from the zone
  • Right-click: Open zone-specific context menu
  • Hover: Show additional information about the zone

Zone Components

Zones are implemented using several components:

#![allow(unused)]
fn main() {
#[derive(Component)]
pub struct ZoneVisualization {
    pub zone_type: Zone,
    pub owner: Option<Entity>,
    pub expanded: bool,
    pub card_count: usize,
    pub layout_style: ZoneLayoutStyle,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ZoneLayoutStyle {
    Grid,
    Stack,
    Fan,
    Cascade,
    List,
}
}

Zone Systems

Several systems manage zone visualization:

  • Zone Layout System: Positions cards within zones
  • Zone Transition System: Handles card movements between zones
  • Zone Interaction System: Processes player interactions with zones
  • Zone Update System: Updates zone visuals based on game state
#![allow(unused)]
fn main() {
fn update_zone_visuals(
    mut zone_query: Query<(&mut ZoneVisualization, &Children)>,
    card_query: Query<Entity, With<Card>>,
    game_state: Res<GameState>,
) {
    for (mut zone, children) in zone_query.iter_mut() {
        // Update card count
        zone.card_count = children.iter()
            .filter(|child| card_query.contains(**child))
            .count();
            
        // Update visuals based on card count and zone type
        // ...
    }
}
}

Multiplayer Considerations

In multiplayer games, zones are arranged to clearly indicate ownership:

  • Player Zones: Positioned relative to each player's seat
  • Shared Zones: Positioned in neutral areas
  • Opponent Zones: Scaled and positioned for visibility

Accessibility Features

Zone visualization includes accessibility considerations:

  • Color Coding: Zones have distinct colors that work with colorblind modes
  • Text Labels: Optional text labels for each zone
  • Screen Reader Support: Zones announce their type and contents
  • Keyboard Navigation: Zones can be selected and manipulated via keyboard

Integration

Zone visualization integrates with:

For more details on specific zone implementations, see the relevant game rules documentation.

Card Visualization

This section covers the systems and components used to visualize cards in the game UI. Card visualization is a critical part of the user experience, as cards are the primary objects players interact with.

Overview

The card visualization system is responsible for:

  • Rendering cards with appropriate artwork, text, and stats
  • Displaying different card states (tapped, flipped, face-down, etc.)
  • Animating card movements and state changes
  • Supporting responsive layout across different screen sizes

Key Components

Implementation

Card visualization uses Bevy's rendering systems and components to create an efficient and visually appealing representation of Magic the Gathering cards.

Card Rendering

The card rendering system is responsible for visualizing Magic the Gathering cards in the game UI.

Card Components

Cards are rendered using several visual components:

  • Card Frame: The border and background of the card, varying by card type
  • Art Box: The image area displaying card artwork
  • Text Box: The area containing card rules text
  • Type Line: The line showing card types and subtypes
  • Mana Cost: Symbols representing the card's casting cost
  • Name Bar: The area displaying the card's name
  • Stats Box: For creatures, the power/toughness display

Rendering Pipeline

Cards are rendered using Bevy's 2D rendering pipeline:

  1. Card data is loaded from the game engine
  2. Visual components are constructed as Bevy entities
  3. Sprites and text elements are positioned according to the card layout
  4. Materials and shaders are applied for visual effects

Optimizations

The rendering system uses several optimizations to maintain performance:

  • Texture Atlases: Card symbols are packed into texture atlases
  • Dynamic LOD: Cards further from the camera show simplified versions
  • Batched Rendering: Similar cards use instanced rendering where possible
  • Caching: Frequently used card layouts are cached

Integration

Card rendering integrates with several other systems:

Example Usage

#![allow(unused)]
fn main() {
// Create a card render entity
commands.spawn((
    SpriteBundle {
        sprite: Sprite {
            color: Color::WHITE,
            custom_size: Some(Vec2::new(CARD_WIDTH, CARD_HEIGHT)),
            ..default()
        },
        transform: Transform::from_translation(Vec3::new(0.0, 0.0, 0.0)),
        ..default()
    },
    CardRender {
        card_entity: card_entity,
        frame_type: FrameType::Creature,
        foil: false,
    },
));
}

For information on how cards integrate with the gameplay systems, see Card Integration.

Card States

This document describes how different card states are visually represented in the game UI.

Common Card States

Cards can be in various states during gameplay:

  • Normal: The default state of a card
  • Tapped: Rotated 90 degrees to indicate it has been used
  • Flipped: Turned 180 degrees per specific card abilities
  • Face-down: Showing the card back instead of the face
  • Revealed: Temporarily shown to specific players
  • Highlighted: Visually emphasized for selection or targeting

Visual Representation

Each state has specific visual cues:

  • Tapped: 90-degree rotation with possible shader effects
  • Flipped: 180-degree rotation along the Y-axis
  • Face-down: Shows the card back texture
  • Revealed: Temporarily elevated z-position with highlight effects
  • Highlighted: Glow effect or outline based on the context

State Transitions

When a card changes state, it undergoes an animation to provide visual feedback:

  • Tap/untap animations are smooth rotations
  • Reveal animations include scaling and elevation changes
  • Highlight animations use pulsing effects

For details on these animations, see Card Animations.

Integration

Card states integrate with:

Implementation

Each state is managed through components and systems that modify the card's visual representation.

Card Animations

This document describes how cards animate between different states and positions in the game UI.

Types of Animations

The animation system supports several types of card animations:

  • Movement: Cards moving between zones or positions
  • State Change: Visual changes when a card's state changes
  • Highlight: Temporary visual effects to draw attention
  • Special Effects: Specific animations for card abilities or events

Animation System

Animations are implemented using Bevy's animation system:

  1. Tweening: Smooth transitions between states using easing functions
  2. Animation Tracks: Sequences of animations that can be chained
  3. Event-driven: Animations trigger in response to game events

Common Animations

Movement Animations

When cards move between zones:

  • Draw: Cards animate from deck to hand
  • Play: Cards animate from hand to battlefield
  • Discard: Cards animate from hand to graveyard
  • Shuffle: Cards animate into the library

State Change Animations

  • Tap/Untap: Smooth rotation animation
  • Flip: 3D rotation along the Y-axis
  • Reveal: Card scales up briefly
  • Transform: Special effect for double-faced cards

Integration

Card animations integrate with:

Implementation Example

#![allow(unused)]
fn main() {
// Example of creating a card draw animation
fn animate_card_draw(
    commands: &mut Commands,
    card_entity: Entity,
    start_pos: Vec3,
    end_pos: Vec3,
) {
    commands.entity(card_entity).insert(AnimationSequence {
        animations: vec![
            Animation::new(
                start_pos,
                end_pos,
                Duration::from_millis(300),
                EaseFunction::CubicOut,
            ),
            Animation::new_scale(
                Vec3::splat(0.9),
                Vec3::ONE,
                Duration::from_millis(150),
                EaseFunction::BackOut,
            ),
        ],
        on_complete: Some(AnimationComplete::CardDrawn),
    });
}
}

For more information on how animations integrate with the game flow, see Game Flow Visualization.

Interaction Systems

This section covers the various interaction systems that allow players to interface with the game. These systems are designed to provide intuitive and responsive control over game elements.

Overview

The interaction systems in Rummage are built to be:

  • Intuitive: Players should be able to understand how to interact with game elements without extensive tutorials
  • Responsive: Interactions should feel snappy and provide appropriate feedback
  • Accessible: Interactions should work across different input methods and with accessibility considerations

Key Interaction Systems

  • Card Selection: How players select cards and receive visual feedback
  • Drag and Drop: How players move cards and other game elements between zones
  • Targeting System: How players select targets for spells and abilities

Implementation Details

Interaction systems are implemented using Bevy's event and component systems. Each interaction type is maintained as a separate module with its own components and systems, but they are designed to work together seamlessly.

Card Selection

The card selection system provides visual feedback and interaction mechanisms for selecting cards in the game.

Selection Modes

The system supports different selection modes:

  • Single selection: Only one card can be selected at a time
  • Multi-selection: Multiple cards can be selected simultaneously (e.g., for mass actions)
  • Range selection: Cards within a range can be selected together
  • Context-aware selection: Selection behavior changes based on game phase or action

Visual Feedback

When a card is selected, visual feedback is provided to the player through:

  • Highlight effects around the card
  • Change in elevation or z-index
  • Animation effects
  • Pulsing glow or outline with phase-appropriate colors
  • Optional sound effects for accessibility

User Input Methods

Cards can be selected through various input methods:

  • Mouse click: Standard selection method
  • Shift+click: Add to current selection (multi-select)
  • Ctrl+click: Toggle selection of individual card
  • Click and drag: Select multiple cards by dragging a selection box
  • Tab navigation: For keyboard-only control
  • Touch: Single tap selects, double tap activates

Selection Context

Selection behavior adapts based on the current game context:

  • Main phase: Selection allows for playing cards or activating abilities
  • Combat phase: Selection highlights potential attackers or blockers
  • Targeting phase: Selection restricted to valid targets
  • Stack interaction: Selection focuses on spells or abilities on the stack

Integration

Card selection integrates with other systems:

Implementation

The selection system uses Bevy's component and event system:

  1. Cards have a Selectable component
  2. Selection state is managed through events
  3. Visual feedback is applied through transform and material changes

Component Structure

#![allow(unused)]
fn main() {
#[derive(Component)]
pub struct Selectable {
    /// Whether the card is currently selected
    pub selected: bool,
    /// Whether the cursor is hovering over the card
    pub hover: bool,
    /// Optional grouping for multi-select operations
    pub selection_group: Option<u32>,
    /// History of selection for undo operations
    pub selection_history: Vec<SelectionState>,
    /// Custom highlight color for this selectable
    pub highlight_color: Option<Color>,
}

#[derive(Event)]
pub struct CardSelectedEvent {
    pub entity: Entity,
    pub selected: bool,
    pub selection_mode: SelectionMode,
}
}

Selection System Implementation

The core selection system consists of several systems:

#![allow(unused)]
fn main() {
/// Process mouse selection inputs
fn process_selection_input(
    mut commands: Commands,
    mouse_input: Res<Input<MouseButton>>,
    keyboard_input: Res<Input<KeyCode>>,
    mut card_query: Query<(Entity, &GlobalTransform, &mut Selectable)>,
    mut selection_events: EventWriter<CardSelectedEvent>,
) {
    // Implementation details
}

/// Update visual appearance based on selection state
fn update_selection_visuals(
    mut commands: Commands,
    mut query: Query<(Entity, &Selectable, Option<&mut Transform>)>,
    time: Res<Time>,
) {
    // Implementation details
}
}

Usage Example

#![allow(unused)]
fn main() {
// Spawn a selectable card
commands.spawn((
    card_bundle,
    Selectable {
        selected: false,
        hover: false,
        selection_group: None,
        selection_history: Vec::new(),
        highlight_color: None,
    },
));

// System to detect selection and perform an action
fn handle_card_selection(
    mut selection_events: EventReader<CardSelectedEvent>,
    card_query: Query<&Card>,
) {
    for event in selection_events.read() {
        if event.selected && card_query.get(event.entity).is_ok() {
            // Card was selected, perform action
            println!("Card selected: {:?}", event.entity);
        }
    }
}
}

Performance Considerations

The selection system is optimized to handle large numbers of selectable entities:

  • Spatial partitioning for efficient hover detection
  • Event-based notifications to avoid polling
  • Selective visual updates for better rendering performance

Future Enhancements

Planned improvements to the selection system include:

  • Customizable selection visuals through themes
  • Advanced multi-select patterns (double-click to select all similar cards)
  • Persistent selection groups for complex game actions
  • AI assistance for suggested selections

For more details on implementing custom selection behavior, see the card integration documentation.

Drag and Drop System

The drag and drop system allows players to move game objects like cards between different zones using intuitive mouse-based interactions.

Components

Draggable

The Draggable component marks entities that can be dragged and contains:

#![allow(unused)]
fn main() {
pub struct Draggable {
    /// Whether the entity is currently being dragged
    pub dragging: bool,
    /// Offset from the mouse cursor to the entity's origin
    pub drag_offset: Vec2,
    /// Z-index for rendering order
    pub z_index: f32,
}
}

Core System

The drag system is implemented in drag_system which handles:

  1. Detecting when a draggable entity is clicked
  2. Starting the drag operation with the appropriate offset
  3. Moving the entity with the mouse cursor
  4. Handling release events to end the drag operation

Z-index Management

The system automatically manages z-indices to ensure that dragged objects appear on top of other game elements. When multiple draggable entities overlap, the one with the highest z-index is selected for dragging.

Integration Points

Card Integration

The drag and drop system integrates with the card visualization system to allow players to:

  • Move cards between hand, battlefield, and other zones
  • Organize cards within a zone
  • Reveal cards or interact with them in specific ways

Targeting Integration

Drag and drop operations are used in conjunction with the targeting system to select targets for spells and abilities. This creates a fluid interaction where players can drag a card to play it and then naturally continue to select targets.

Plugin Setup

To enable drag and drop functionality, the DragPlugin must be added to your Bevy app:

#![allow(unused)]
fn main() {
app.add_plugins(DragPlugin);
}

Implementation Example

Below is a simplified example of how to make an entity draggable:

#![allow(unused)]
fn main() {
// Spawn a card entity with a draggable component
commands.spawn((
    card_bundle,
    Draggable {
        dragging: false,
        drag_offset: Vec2::ZERO,
        z_index: 1.0,
    },
));
}

Customization

The drag and drop system can be customized by:

  1. Adding different visual feedback during dragging
  2. Implementing custom drop target detection
  3. Creating drag constraints for certain game zones

For more information on implementing custom drag behaviors, see the game state integration documentation.

Targeting System

The targeting system allows players to select targets for spells, abilities, and other effects.

Core Concepts

The targeting system consists of:

  • Target Sources: Cards or abilities that require targets
  • Target Validators: Rules that determine valid targets
  • Target Selectors: UI elements that allow players to select targets
  • Visual Feedback: Effects that show targeting in progress

Targeting Flow

  1. Player initiates targeting (by playing a card or activating an ability)
  2. System highlights valid targets based on game rules
  3. Player selects target(s)
  4. System validates the selection
  5. Action completes with chosen targets

Component Structure

#![allow(unused)]
fn main() {
pub struct TargetSource {
    pub targeting_active: bool,
    pub required_targets: usize,
    pub current_targets: Vec<Entity>,
    pub target_rules: TargetRules,
}

pub struct Targetable {
    pub can_be_targeted: bool,
    pub highlight_color: Color,
}
}

Visual Effects

The targeting system provides several visual cues:

  • Highlighting valid targets
  • Animated arcs or arrows from source to target
  • Pulsing effects on potential targets
  • Confirmation animations when targets are selected

Integration

The targeting system integrates with:

Implementation Example

#![allow(unused)]
fn main() {
// Create a spell that requires targeting
commands.spawn((
    card_bundle,
    TargetSource {
        targeting_active: false,
        required_targets: 1,
        current_targets: Vec::new(),
        target_rules: TargetRules::CreaturesOnly,
    },
));

// Make a creature targetable
commands.spawn((
    creature_bundle,
    Targetable {
        can_be_targeted: true,
        highlight_color: Color::rgb(0.9, 0.3, 0.3),
    },
));
}

For more details on targeting rules, see the Magic rules on targeting.

Virtual Table Layout

This document details the virtual table system used in Rummage for multiplayer Commander games. The table design accommodates anywhere from 2 to 6+ players while maintaining an intuitive and functional interface.

Table of Contents

  1. Overview
  2. Adaptive Player Positioning
  3. Shared Zones
  4. Implementation Details
  5. Testing

Overview

The virtual table serves as the central organizing element of the game UI. It:

  • Positions players around a central battlefield
  • Scales dynamically based on player count
  • Provides appropriate space for individual and shared game zones
  • Maintains visual clarity regardless of player count

The table's design simulates sitting around a physical table but takes advantage of the digital medium to maximize usability and information presentation.

Adaptive Player Positioning

Player Count Configurations

The table dynamically adjusts to accommodate different player counts:

Two Player Configuration

┌─────────────────────────────────────────────────────┐
│                                                     │
│               OPPONENT PLAYMAT                      │
│                                                     │
├─────────────────────────────────────────────────────┤
│                                                     │
│                 SHARED AREA                         │
│          (Command Zone, Stack, etc.)                │
│                                                     │
├─────────────────────────────────────────────────────┤
│                                                     │
│                 PLAYER PLAYMAT                      │
│                                                     │
└─────────────────────────────────────────────────────┘

Three Player Configuration

┌─────────────────────────────────────────────────────┐
│                                                     │
│    OPPONENT 1 PLAYMAT      OPPONENT 2 PLAYMAT       │
│                                                     │
├─────────────────────────────────────────────────────┤
│                                                     │
│                 SHARED AREA                         │
│          (Command Zone, Stack, etc.)                │
│                                                     │
├─────────────────────────────────────────────────────┤
│                                                     │
│                 PLAYER PLAYMAT                      │
│                                                     │
└─────────────────────────────────────────────────────┘

Four Player Configuration (Default)

┌─────────────────────────────────────────────────────┐
│                                                     │
│               OPPONENT 2 PLAYMAT                    │
│                                                     │
├────────────────┬────────────────┬──────────────────┤
│                │                │                  │
│  OPPONENT 1    │  SHARED AREA   │   OPPONENT 3     │
│   PLAYMAT      │ (Command Zone, │     PLAYMAT      │
│                │  Stack, etc.)  │                  │
│                │                │                  │
├────────────────┴────────────────┴──────────────────┤
│                                                     │
│                 PLAYER PLAYMAT                      │
│                                                     │
└─────────────────────────────────────────────────────┘

Five+ Player Configuration

For five or more players, the table uses a scrollable/zoomable view that arranges players in a circular pattern, with the active player and their adjacent opponents given visual priority.

Adaptive Positioning Algorithm

The table employs an adaptive algorithm that:

  1. Places the local player at the bottom
  2. Positions opponents based on turn order
  3. Allocates screen space proportionally based on player count
  4. Adjusts element scaling to maintain readability
  5. Prioritizes visibility for the active player and important game elements

Shared Zones

The virtual table includes shared game zones accessible to all players:

Command Zone

Central area for commander cards, emblems, and other command-zone specific elements.

The Stack

Visual representation of spells and abilities currently on the stack, showing order and targeting information.

Exile Zone

Shared exile area for face-up exiled cards that should be visible to all players.

Game Information Display

Shows game state information such as turn number, active phase, priority holder, etc.

Implementation Details

The table is implemented using Bevy's UI system with hierarchical nodes:

#![allow(unused)]
fn main() {
fn setup_virtual_table(
    mut commands: Commands,
    game_state: Res<GameState>,
    asset_server: Res<AssetServer>,
) {
    // Main table container
    commands
        .spawn((
            Node {
                width: Val::Percent(100.0),
                height: Val::Percent(100.0),
                flex_direction: FlexDirection::Column,
                ..default()
            },
            VirtualTable,
            AppLayer::GameUI.layer(),
        ))
        .with_children(|table| {
            // Layout depends on player count
            match game_state.player_count {
                2 => setup_two_player_layout(table, &asset_server),
                3 => setup_three_player_layout(table, &asset_server),
                4 => setup_four_player_layout(table, &asset_server),
                _ => setup_many_player_layout(table, &asset_server, game_state.player_count),
            }
        });
}
}

Player Position Calculation

Each player's position is calculated based on the total player count and their index:

#![allow(unused)]
fn main() {
/// Calculate angle and position for player zones based on player count
fn calculate_player_position(player_index: usize, player_count: usize) -> Vec2 {
    let angle = (player_index as f32 / player_count as f32) * std::f32::consts::TAU;
    let distance = 400.0; // Distance from center
    Vec2::new(
        distance * angle.cos(),
        distance * angle.sin(),
    )
}
}

Dynamic Resizing

The table responds to window size changes, maintaining appropriate proportions:

#![allow(unused)]
fn main() {
fn update_table_responsive_layout(
    mut query: Query<&mut Node, With<VirtualTable>>,
    window: Query<&Window>,
) {
    let window = window.single();
    let aspect_ratio = window.width() / window.height();
    
    for mut node in query.iter_mut() {
        // Adjust layout based on aspect ratio
        if aspect_ratio > 1.5 {
            // Landscape orientation
            node.flex_direction = FlexDirection::Column;
        } else {
            // Portrait orientation
            node.flex_direction = FlexDirection::Row;
        }
    }
}
}

Testing

The virtual table's adaptive layout should be thoroughly tested across different scenarios:

Unit Tests

  • Test player position calculation for different player counts
  • Verify layout component hierarchy for each player configuration
  • Test responsive resizing behavior

Integration Tests

  • Verify all player zones are visible and accessible
  • Test navigation between player areas
  • Ensure shared zones maintain visibility for all players

Visual Tests

  • Confirm layout appearance across different screen sizes
  • Verify scaling behavior for UI elements
  • Test readability of cards and game information

Performance Tests

  • Measure rendering performance with maximum player count
  • Test scrolling/zooming smoothness
  • Verify UI responsiveness during layout transitions

Battlefield Layout

Card Stacking

Player Playmat

This document details the player playmat system in Rummage, representing each player's personal play area with all the zones required for Magic: The Gathering Commander gameplay.

Table of Contents

  1. Overview
  2. Playmat Zones
  3. Zone Interactions
  4. Adaptive Sizing
  5. Visual Customization
  6. Implementation Details
  7. Testing

Overview

Each player in a Rummage game has their own playmat that:

  • Contains all required Magic: The Gathering game zones
  • Provides clear visual separation between zones
  • Ensures cards and game elements are easily accessible
  • Adapts based on available screen space
  • Integrates with the overall virtual table layout

Playmat Zones

The playmat includes the following zones, as dictated by Magic: The Gathering rules:

Battlefield

The primary play area where permanents (creatures, artifacts, enchantments, planeswalkers, lands) are placed when cast.

┌─────────────────────────────────────────────────────┐
│                                                     │
│                                                     │
│                                                     │
│                  BATTLEFIELD                        │
│                                                     │
│                                                     │
│                                                     │
└─────────────────────────────────────────────────────┘
  • Supports tapped/untapped card states
  • Organizes cards by type (creatures, lands, etc.)
  • Allows custom grouping of permanents
  • Supports tokens and copy effects

Hand

The player's hand of cards, visible only to the player (and spectators with appropriate permissions).

┌─────────────────────────────────────────────────────┐
│                                                     │
│                      HAND                           │
│                                                     │
└─────────────────────────────────────────────────────┘
  • Displays cards in a fan-out layout
  • Provides detail view on hover/select
  • Shows card count for opponents
  • Supports sorting and filtering

Library (Deck)

The player's library, from which they draw cards.

┌───────────┐
│           │
│  LIBRARY  │
│           │
└───────────┘
  • Shows deck position and card count
  • Animates card draw and shuffle
  • Displays deck state (e.g., being searched)
  • Supports placing cards on top/bottom of library

Graveyard

Discard pile for cards that have been destroyed, discarded, or sacrificed.

┌───────────┐
│           │
│ GRAVEYARD │
│           │
└───────────┘
  • Shows most recent cards on top
  • Supports browsing full contents
  • Displays count of cards in graveyard
  • Animates cards entering the graveyard

Exile

Area for cards removed from the game.

┌───────────┐
│           │
│   EXILE   │
│           │
└───────────┘
  • Distinguishes between face-up and face-down exiled cards
  • Groups cards by the effect that exiled them
  • Shows duration for temporary exile effects

Command Zone

Special zone for commander cards and emblems.

┌───────────┐
│           │
│  COMMAND  │
│   ZONE    │
│           │
└───────────┘
  • Prominently displays the player's commander(s)
  • Shows commander tax amount
  • Tracks commander damage given/received
  • Displays emblems and other command zone objects

Complete Playmat Layout

The full playmat integrates these zones into a cohesive layout:

┌─────────────────────────────────────────────────────┐
│                      HAND                           │
├───────────┬───────────────────────────┬─────────────┤
│           │                           │             │
│  LIBRARY  │                           │  GRAVEYARD  │
│           │                           │             │
├───────────┤                           ├─────────────┤
│           │                           │             │
│           │       BATTLEFIELD         │             │
│   EXILE   │                           │  COMMAND    │
│           │                           │   ZONE      │
│           │                           │             │
└───────────┴───────────────────────────┴─────────────┘

Zone Interactions

The playmat facilitates several interactions between zones:

Card Movement

Cards can move between zones through:

  • Drag and drop gestures
  • Context menu actions
  • Keyboard shortcuts
  • Game action triggers

Each movement includes appropriate animations to indicate the source and destination zones.

Zone Focus

A zone can be focused to display more detailed information:

  • Expanded graveyard view
  • Detailed hand card inspection
  • Library search views

Focus interactions maintain context by showing the relationship to other zones.

Adaptive Sizing

The playmat adapts to different screen sizes and player counts:

Size Adaptations

  • Full View: When it's the player's turn or they have priority
  • Compact View: During opponent turns
  • Minimal View: When the player is not directly involved in the current game action

Active Zone Emphasis

The current phase of the game influences zone emphasis:

  • During main phases, the battlefield and hand are emphasized
  • During combat, the battlefield receives more space
  • When searching library, the library zone expands

Visual Customization

Players can customize their playmat's appearance:

  • Custom playmat backgrounds
  • Zone color themes
  • Card arrangement preferences
  • Animation settings

Implementation Details

The playmat is implemented using Bevy's UI system:

#![allow(unused)]
fn main() {
fn setup_player_playmat(
    mut commands: Commands,
    player: &Player,
    asset_server: &Res<AssetServer>,
) {
    // Main playmat container
    commands
        .spawn((
            Node {
                width: Val::Percent(100.0),
                height: Val::Percent(100.0),
                flex_direction: FlexDirection::Column,
                ..default()
            },
            PlayerPlaymat { player_id: player.id },
            AppLayer::GameUI.layer(),
        ))
        .with_children(|playmat| {
            // Hand zone
            setup_hand_zone(playmat, player, asset_server);
            
            // Middle section containing battlefield and side zones
            playmat
                .spawn(Node {
                    width: Val::Percent(100.0),
                    height: Val::Percent(80.0),
                    flex_direction: FlexDirection::Row,
                    ..default()
                })
                .with_children(|middle| {
                    // Left side zones
                    setup_left_zones(middle, player, asset_server);
                    
                    // Battlefield
                    setup_battlefield(middle, player, asset_server);
                    
                    // Right side zones
                    setup_right_zones(middle, player, asset_server);
                });
        });
}
}

Zone Components

Each zone is implemented as a component that can be queried and manipulated:

#![allow(unused)]
fn main() {
/// Component for the player's hand zone
#[derive(Component)]
struct HandZone {
    player_id: PlayerId,
    expanded: bool,
}

/// Component for the battlefield zone
#[derive(Component)]
struct BattlefieldZone {
    player_id: PlayerId,
    organization: BattlefieldOrganization,
}

// Other zone components follow a similar pattern
}

Responsive Layout Systems

Systems manage the responsive behavior of the playmat:

#![allow(unused)]
fn main() {
fn update_playmat_layout(
    mut playmat_query: Query<(&mut Node, &PlayerPlaymat)>,
    player_turn_query: Query<&ActiveTurn>,
    window: Query<&Window>,
) {
    let window = window.single();
    let active_turn = player_turn_query.single();
    let is_landscape = window.width() > window.height();
    
    for (mut node, playmat) in playmat_query.iter_mut() {
        let is_active_player = active_turn.player_id == playmat.player_id;
        
        // Adjust layout based on active player and orientation
        if is_active_player {
            node.height = Val::Percent(if is_landscape { 40.0 } else { 50.0 });
        } else {
            node.height = Val::Percent(if is_landscape { 20.0 } else { 25.0 });
        }
    }
}
}

Testing

The playmat should be thoroughly tested to ensure proper functionality and visual appearance:

Unit Tests

  • Test correct initialization of all zones
  • Verify component hierarchy
  • Ensure proper event handling for zone interactions

Integration Tests

  • Test card movement between zones
  • Verify zone focus behavior
  • Test responsive layout changes

Visual Tests

  • Verify appearance across different screen sizes
  • Test with different card counts in each zone
  • Ensure readability of card information

Gameplay Tests

  • Test common gameplay patterns involving multiple zones
  • Verify commander damage tracking
  • Test special zone interactions like flashback from graveyard

Zone Design

Visual Themes

In-Game Chat System

This document details the in-game chat system in Rummage, providing communication capabilities for players during Commander format games.

Table of Contents

  1. Overview
  2. Chat System Components
  3. Integration With Game UI
  4. Accessibility Features
  5. Implementation Details
  6. Testing
  7. Related Documentation

Overview

The in-game chat system provides multiple communication channels for players during gameplay:

  • Text Chat: Traditional text-based communication
  • Voice Chat: Real-time audio communication
  • Game Event Messages: Automated messages about game events
  • Emotes and Reactions: Pre-defined expressions and reactions

The chat system is designed to be non-intrusive while remaining easily accessible during gameplay, enhancing the social experience of Commander format games.

Chat System Components

The chat system consists of several integrated components:

Text Chat

The text chat component allows players to type and send messages to others in the game. Features include:

  • Chat Channels: Public, private, and team channels
  • Message Formatting: Support for basic text formatting
  • Command System: Chat commands for game actions
  • Message History: Accessible chat history with search capabilities

Detailed Text Chat Documentation

Voice Chat

The voice chat component enables real-time audio communication between players:

  • Push-to-Talk: Configurable key binding for activating microphone
  • Voice Activity Detection: Optional automatic activation based on speech
  • Player Indicators: Visual cues showing who is speaking
  • Individual Volume Controls: Adjust volume for specific players

Detailed Voice Chat Documentation

Game Event Messages

Automated messages about game actions and events:

  • Stackable Notifications: Collapsible event messages
  • Filtering Options: Configure which events generate messages
  • Verbosity Settings: Adjust level of detail in event messages
  • Highlighting: Color coding for important events

Emotes and Reactions

Quick non-verbal communication options:

  • Contextual Emotes: Reactions appropriate to game context
  • Emote Wheel: Quick access to common emotes
  • Custom Emotes: Limited customization options
  • Cooldown System: Prevents emote spam

Integration With Game UI

The chat system integrates seamlessly with the game UI:

Chat Window Modes

The chat window can appear in multiple states:

  • Expanded View: Full chat interface with history and channels
  • Minimized View: Condensed view showing recent messages
  • Hidden: Completely hidden with notification indicators for new messages
  • Pop-out: Detachable window for multi-monitor setups

Positioning

The chat interface can be positioned in different areas:

  • Default: Bottom-left corner of the screen
  • Customizable: User can reposition within constraints
  • Contextual: Automatic repositioning based on game state
  • Size Adjustable: Resizable chat window

Visual Integration

Visual elements that tie the chat system to the game:

  • Player Color Coding: Message colors match player identities
  • Thematic Styling: Chat UI follows game's visual language
  • Transition Effects: Smooth animations for state changes
  • Focus Management: Proper keyboard focus handling

Accessibility Features

The chat system includes several accessibility features:

  • Text-to-Speech: Optional reading of incoming messages
  • Speech-to-Text: Voice transcription for voice chat
  • High Contrast Mode: Improved readability options
  • Customizable Text Size: Adjustable font sizes
  • Keyboard Navigation: Complete keyboard control
  • Alternative Communication: Pre-defined phrases for quick communication
  • Message Timing: Configurable message display duration

Implementation Details

The chat system is implemented using Bevy's ECS architecture:

Components

#![allow(unused)]
fn main() {
/// Component for the chat window
#[derive(Component)]
struct ChatWindow {
    mode: ChatMode,
    active_channel: ChatChannel,
    position: ChatPosition,
    is_focused: bool,
}

/// Component for input field
#[derive(Component)]
struct ChatInput {
    text: String,
    cursor_position: usize,
    selection_range: Option<(usize, usize)>,
}

/// Component for chat message display
#[derive(Component)]
struct ChatMessageDisplay {
    messages: Vec<ChatMessage>,
    scroll_position: f32,
    filter_settings: ChatFilterSettings,
}

/// Component for voice activity
#[derive(Component)]
struct VoiceActivity {
    is_active: bool,
    volume_level: f32,
    player_id: PlayerId,
}

/// Resource for chat settings
#[derive(Resource)]
struct ChatSettings {
    text_chat_enabled: bool,
    voice_chat_enabled: bool,
    message_history_size: usize,
    notification_settings: NotificationSettings,
    accessibility_settings: ChatAccessibilitySettings,
}
}

Systems

#![allow(unused)]
fn main() {
/// System to handle incoming chat messages
fn handle_chat_messages(
    mut messages_query: Query<&mut ChatMessageDisplay>,
    chat_events: EventReader<ChatMessageEvent>,
    settings: Res<ChatSettings>,
) {
    // Implementation
}

/// System to handle voice chat
fn process_voice_chat(
    mut voice_activity_query: Query<(&mut VoiceActivity, &PlayerId)>,
    audio_input: Res<AudioInputBuffer>,
    settings: Res<ChatSettings>,
) {
    // Implementation
}

/// System to update chat UI
fn update_chat_ui(
    mut chat_window_query: Query<(&mut ChatWindow, &mut Node, &Children)>,
    keyboard_input: Res<ButtonInput<KeyCode>>,
    time: Res<Time>,
) {
    // Implementation
}
}

Chat Window Setup

#![allow(unused)]
fn main() {
/// Setup the chat window
fn setup_chat_window(
    mut commands: Commands,
    asset_server: Res<AssetServer>,
    chat_settings: Res<ChatSettings>,
) {
    // Main chat container
    commands
        .spawn((
            ChatWindow {
                mode: ChatMode::Minimized,
                active_channel: ChatChannel::Global,
                position: ChatPosition::BottomLeft,
                is_focused: false,
            },
            Node {
                width: Val::Px(400.0),
                height: Val::Px(300.0),
                position_type: PositionType::Absolute,
                bottom: Val::Px(10.0),
                left: Val::Px(10.0),
                flex_direction: FlexDirection::Column,
                ..default()
            },
            BackgroundColor(Color::rgba(0.1, 0.1, 0.1, 0.8)),
            BorderColor(Color::rgb(0.3, 0.3, 0.3)),
            Outline::new(Val::Px(1.0)),
            AppLayer::GameUI.layer(),
            Visibility::Visible,
        ))
        .with_children(|parent| {
            // Chat header
            setup_chat_header(parent, &asset_server);
            
            // Message display area
            setup_message_display(parent, &asset_server);
            
            // Chat input area
            setup_chat_input(parent, &asset_server);
            
            // Voice chat indicators
            if chat_settings.voice_chat_enabled {
                setup_voice_indicators(parent, &asset_server);
            }
        });
}
}

Testing

The chat system requires thorough testing to ensure reliability and performance:

Unit Tests

  • Test message processing
  • Verify channel functionality
  • Test input handling
  • Validate filtering systems

Integration Tests

  • Test chat integration with game events
  • Verify voice chat synchronization
  • Test accessibility features
  • Validate UI responsiveness

Performance Tests

  • Test with high message volume
  • Measure voice chat latency
  • Verify memory usage with large chat history
  • Test network bandwidth usage

Usability Tests

  • Validate readability
  • Test keyboard navigation
  • Verify mobile touch interactions
  • Test with screen readers

Message Display

Emote System

Chat System API for Plugins

This document provides detailed information about the chat system API that plugin developers can use to integrate with Rummage's in-game communication system.

Table of Contents

  1. Overview
  2. Core API Components
  3. Text Chat Integration
  4. Voice Chat Integration
  5. Events and Messages
  6. UI Customization
  7. Examples
  8. Best Practices

Overview

The chat API allows plugin developers to integrate with the existing chat system, enabling custom chat commands, specialized message formats, voice chat extensions, and UI customizations. This API is designed to be flexible while maintaining compatibility with the core chat functionality.

Key API features:

  • Send and receive chat messages through events
  • Register custom chat commands
  • Add custom UI elements to the chat interface
  • Hook into voice activity detection
  • Add specialized message types
  • Access chat history

Core API Components

Chat Plugin

The main entry point for plugins integrating with the chat system:

#![allow(unused)]
fn main() {
/// Plugin for chat system integration
pub struct ChatPlugin;

impl Plugin for ChatPlugin {
    fn build(&self, app: &mut App) {
        app.add_event::<ChatMessageEvent>()
           .add_event::<VoicePacketEvent>()
           .add_event::<ChatCommandEvent>()
           .init_resource::<ChatSettings>()
           .init_resource::<VoiceChatConfig>()
           .add_systems(Startup, setup_chat_system)
           .add_systems(Update, (
               process_chat_messages,
               handle_chat_commands,
               update_chat_ui,
               process_voice_chat,
           ));
    }
}
}

Public Resources

Resources available for plugins to access and modify:

#![allow(unused)]
fn main() {
/// Settings for the chat system
#[derive(Resource, Clone)]
pub struct ChatSettings {
    pub text_chat_enabled: bool,
    pub voice_chat_enabled: bool,
    pub message_history_size: usize,
    pub notification_settings: NotificationSettings,
    pub accessibility_settings: ChatAccessibilitySettings,
    pub command_prefix: String,
}

/// Voice chat configuration
#[derive(Resource, Clone)]
pub struct VoiceChatConfig {
    pub enabled: bool,
    pub input_device_id: Option<String>,
    pub output_device_id: Option<String>,
    pub input_volume: f32,
    pub output_volume: f32,
    pub activation_mode: VoiceActivationMode,
    pub activation_threshold: f32,
    pub push_to_talk_key: KeyCode,
    pub noise_suppression_level: NoiseSuppressionLevel,
    pub echo_cancellation: bool,
    pub auto_gain_control: bool,
}

/// Chat message history resource
#[derive(Resource)]
pub struct ChatHistory {
    pub messages: Vec<ChatMessage>,
    pub channels: HashMap<ChatChannel, Vec<ChatMessage>>,
    pub last_read_indices: HashMap<ChatChannel, usize>,
}
}

Public Components

Components that plugins can query and modify:

#![allow(unused)]
fn main() {
/// Chat window component
#[derive(Component)]
pub struct ChatWindow {
    pub mode: ChatMode,
    pub active_channel: ChatChannel,
    pub position: ChatPosition,
    pub is_focused: bool,
}

/// Chat message display component
#[derive(Component)]
pub struct ChatMessageDisplay {
    pub messages: Vec<ChatMessage>,
    pub scroll_position: f32,
    pub filter_settings: ChatFilterSettings,
}

/// Voice activity component
#[derive(Component)]
pub struct VoiceActivity {
    pub is_active: bool,
    pub volume_level: f32,
    pub player_id: PlayerId,
}
}

Public Events

Events that plugins can send and receive:

#![allow(unused)]
fn main() {
/// Event for chat messages
#[derive(Event, Clone)]
pub struct ChatMessageEvent {
    pub message: ChatMessage,
    pub recipients: MessageRecipients,
}

/// Event for voice packets
#[derive(Event)]
pub struct VoicePacketEvent {
    pub player_id: PlayerId,
    pub audio_data: Vec<u8>,
    pub sequence_number: u32,
    pub timestamp: f64,
    pub channel: VoiceChannel,
}

/// Event for chat commands
#[derive(Event)]
pub struct ChatCommandEvent {
    pub command: String,
    pub args: Vec<String>,
    pub sender_id: PlayerId,
    pub channel: ChatChannel,
}
}

Text Chat Integration

Sending Messages

Plugins can send messages to the chat system:

#![allow(unused)]
fn main() {
/// Send a chat message from a plugin
pub fn send_chat_message(
    message_text: &str,
    sender_name: &str,
    message_type: MessageType,
    channel: ChatChannel,
    message_events: &mut EventWriter<ChatMessageEvent>,
) {
    let message = ChatMessage {
        sender_id: None,  // None indicates system/plugin sender
        sender_name: sender_name.to_string(),
        content: message_text.to_string(),
        timestamp: std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .unwrap_or_default()
            .as_secs_f64(),
        channel,
        message_type,
        formatting: None,
    };
    
    message_events.send(ChatMessageEvent {
        message,
        recipients: MessageRecipients::All,
    });
}
}

Reading Messages

Plugins can read messages from the chat system:

#![allow(unused)]
fn main() {
/// System to read chat messages in a plugin
pub fn read_chat_messages(
    mut message_events: EventReader<ChatMessageEvent>,
    mut plugin_state: ResMut<MyPluginState>,
) {
    for event in message_events.read() {
        // Process incoming messages
        plugin_state.process_message(&event.message);
        
        // Log or analyze messages
        if event.message.content.contains(&plugin_state.keyword) {
            // Do something with this message
        }
    }
}
}

Registering Custom Commands

Plugins can register custom chat commands:

#![allow(unused)]
fn main() {
/// Register a custom chat command
pub fn register_chat_command(
    command: &str,
    description: &str,
    mut commands: ResMut<ChatCommandRegistry>,
) {
    commands.register(
        command.to_string(),
        ChatCommandInfo {
            description: description.to_string(),
            permission_level: PermissionLevel::User,
            handler: ChatCommandHandler::Plugin("my_plugin".to_string()),
        },
    );
}

/// System to handle custom chat commands
pub fn handle_custom_commands(
    mut command_events: EventReader<ChatCommandEvent>,
    mut message_events: EventWriter<ChatMessageEvent>,
    // Plugin-specific resources and access
) {
    for event in command_events.read() {
        if event.command == "my_custom_command" {
            // Handle the custom command
            // ...
            
            // Send response
            send_chat_message(
                "Command processed successfully!",
                "MyPlugin",
                MessageType::System,
                event.channel,
                &mut message_events,
            );
        }
    }
}
}

Voice Chat Integration

Voice Activity Detection

Plugins can hook into voice activity detection:

#![allow(unused)]
fn main() {
/// System to monitor voice activity
pub fn monitor_voice_activity(
    voice_activity: Query<&VoiceActivity>,
    mut plugin_state: ResMut<MyPluginVoiceState>,
) {
    for activity in &voice_activity {
        if activity.is_active {
            // Player is speaking
            plugin_state.player_speaking(activity.player_id, activity.volume_level);
        } else if plugin_state.was_speaking(activity.player_id) {
            // Player stopped speaking
            plugin_state.player_stopped_speaking(activity.player_id);
        }
    }
}
}

Voice Processing

Plugins can process voice audio:

#![allow(unused)]
fn main() {
/// System to process voice packets
pub fn process_voice_packets(
    mut voice_events: EventReader<VoicePacketEvent>,
    mut processed_voice_events: EventWriter<VoicePacketEvent>,
    plugin_voice_processor: Res<MyVoiceProcessor>,
) {
    for event in voice_events.read() {
        // Get the audio data
        let audio_data = &event.audio_data;
        
        // Process the audio (e.g., apply effects, filter, etc.)
        let processed_data = plugin_voice_processor.process_audio(audio_data);
        
        // Create a new event with processed data
        processed_voice_events.send(VoicePacketEvent {
            player_id: event.player_id,
            audio_data: processed_data,
            sequence_number: event.sequence_number,
            timestamp: event.timestamp,
            channel: event.channel,
        });
    }
}
}

Events and Messages

Custom Message Types

Plugins can define custom message types:

#![allow(unused)]
fn main() {
/// Define custom message types
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum CustomMessageType {
    Achievement,
    Tip,
    Warning,
    Debug,
}

/// Convert custom message type to standard type
impl From<CustomMessageType> for MessageType {
    fn from(custom_type: CustomMessageType) -> Self {
        match custom_type {
            CustomMessageType::Achievement => MessageType::System,
            CustomMessageType::Tip => MessageType::System,
            CustomMessageType::Warning => MessageType::Error,
            CustomMessageType::Debug => MessageType::System,
        }
    }
}

/// Send a custom message
pub fn send_custom_message(
    text: &str,
    custom_type: CustomMessageType,
    mut message_events: EventWriter<ChatMessageEvent>,
) {
    let message_type: MessageType = custom_type.into();
    
    // Create message with custom formatting based on type
    let formatting = match custom_type {
        CustomMessageType::Achievement => Some(MessageFormatting {
            color: Some(Color::GOLD),
            icon: Some("achievement".to_string()),
            ..Default::default()
        }),
        CustomMessageType::Tip => Some(MessageFormatting {
            color: Some(Color::BLUE),
            icon: Some("tip".to_string()),
            ..Default::default()
        }),
        // ...other types
        _ => None,
    };
    
    let message = ChatMessage {
        sender_id: None,
        sender_name: match custom_type {
            CustomMessageType::Achievement => "Achievement Unlocked".to_string(),
            CustomMessageType::Tip => "Tip".to_string(),
            CustomMessageType::Warning => "Warning".to_string(),
            CustomMessageType::Debug => "Debug".to_string(),
        },
        content: text.to_string(),
        timestamp: std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .unwrap_or_default()
            .as_secs_f64(),
        channel: ChatChannel::System,
        message_type,
        formatting,
    };
    
    message_events.send(ChatMessageEvent {
        message,
        recipients: MessageRecipients::All,
    });
}
}

Event Interception

Plugins can intercept and modify chat events:

#![allow(unused)]
fn main() {
/// System to intercept chat messages
pub fn intercept_chat_messages(
    mut reader: EventReader<ChatMessageEvent>,
    mut writer: EventWriter<ChatMessageEvent>,
    plugin_config: Res<MyPluginConfig>,
) {
    for event in reader.read() {
        // Make a mutable copy of the event
        let mut modified_event = event.clone();
        
        // Apply modifications based on plugin rules
        if plugin_config.should_filter_profanity {
            modified_event.message.content = filter_profanity(&modified_event.message.content);
        }
        
        if plugin_config.should_translate && event.message.content.starts_with("!translate") {
            // Translate message to selected language
            modified_event.message.content = translate_message(
                &modified_event.message.content,
                plugin_config.target_language.clone(),
            );
        }
        
        // Forward the modified event
        writer.send(modified_event);
    }
}
}

UI Customization

Adding UI Elements

Plugins can add custom UI elements to the chat interface:

#![allow(unused)]
fn main() {
/// System to add custom UI elements to chat
pub fn setup_custom_chat_ui(
    mut commands: Commands,
    chat_window_query: Query<Entity, With<ChatWindow>>,
    asset_server: Res<AssetServer>,
) {
    if let Ok(chat_entity) = chat_window_query.get_single() {
        // Add a custom button to the chat window
        commands.entity(chat_entity).with_children(|parent| {
            parent.spawn((
                CustomChatButton::TranslateToggle,
                ButtonBundle {
                    style: Style {
                        width: Val::Px(24.0),
                        height: Val::Px(24.0),
                        // ...other style properties
                        ..default()
                    },
                    background_color: BackgroundColor(Color::rgba(0.3, 0.5, 0.8, 0.6)),
                    ..default()
                },
            ))
            .with_children(|button| {
                button.spawn(Text2d {
                    text: "🌐".into(),
                    font: asset_server.load("fonts/NotoEmoji-Regular.ttf"),
                    font_size: 16.0,
                    // ...other text properties
                });
            });
        });
    }
}

/// System to handle custom UI interactions
pub fn handle_custom_chat_ui(
    interaction_query: Query<
        (&Interaction, &CustomChatButton),
        (Changed<Interaction>, With<Button>),
    >,
    mut plugin_config: ResMut<MyPluginConfig>,
) {
    for (interaction, button) in &interaction_query {
        if *interaction == Interaction::Pressed {
            match button {
                CustomChatButton::TranslateToggle => {
                    // Toggle translation feature
                    plugin_config.should_translate = !plugin_config.should_translate;
                },
                // Handle other custom buttons
                // ...
            }
        }
    }
}
}

Styling Messages

Plugins can define custom message styles:

#![allow(unused)]
fn main() {
/// Define custom message style
pub struct CustomMessageStyle {
    pub background_color: Color,
    pub text_color: Color,
    pub border_color: Option<Color>,
    pub icon: Option<String>,
    pub font_style: FontStyle,
}

/// Register custom message styles
pub fn register_custom_styles(
    mut style_registry: ResMut<ChatStyleRegistry>,
) {
    // Register achievement style
    style_registry.register(
        "achievement",
        CustomMessageStyle {
            background_color: Color::rgba(0.1, 0.1, 0.1, 0.9),
            text_color: Color::rgb(1.0, 0.84, 0.0),  // Gold
            border_color: Some(Color::rgb(0.8, 0.7, 0.0)),
            icon: Some("trophy".to_string()),
            font_style: FontStyle::Bold,
        },
    );
    
    // Register other styles
    // ...
}
}

Examples

Basic Chat Command Plugin

A simple plugin that adds a dice rolling command:

#![allow(unused)]
fn main() {
/// Dice roller plugin
pub struct DiceRollerPlugin;

impl Plugin for DiceRollerPlugin {
    fn build(&self, app: &mut App) {
        app.init_resource::<DiceRollerConfig>()
           .add_systems(Startup, register_dice_commands)
           .add_systems(Update, handle_dice_commands);
    }
}

/// Configuration for dice roller
#[derive(Resource, Default)]
struct DiceRollerConfig {
    max_dice: u32,
    max_sides: u32,
}

/// Register dice rolling commands
fn register_dice_commands(
    mut command_registry: ResMut<ChatCommandRegistry>,
) {
    command_registry.register(
        "roll".to_string(),
        ChatCommandInfo {
            description: "Roll dice. Usage: /roll XdY".to_string(),
            permission_level: PermissionLevel::User,
            handler: ChatCommandHandler::Plugin("dice_roller".to_string()),
        },
    );
}

/// Handle dice rolling commands
fn handle_dice_commands(
    mut command_events: EventReader<ChatCommandEvent>,
    mut message_events: EventWriter<ChatMessageEvent>,
    config: Res<DiceRollerConfig>,
) {
    for event in command_events.read() {
        if event.command == "roll" {
            // Parse arguments (e.g., "2d6")
            if let Some(arg) = event.args.first() {
                if let Some((count_str, sides_str)) = arg.split_once('d') {
                    // Parse dice count and sides
                    if let (Ok(count), Ok(sides)) = (count_str.parse::<u32>(), sides_str.parse::<u32>()) {
                        // Validate against config limits
                        if count > 0 && count <= config.max_dice && sides > 0 && sides <= config.max_sides {
                            // Roll the dice
                            let mut rng = rand::thread_rng();
                            let rolls: Vec<u32> = (0..count)
                                .map(|_| rng.gen_range(1..=sides))
                                .collect();
                            
                            let total: u32 = rolls.iter().sum();
                            
                            // Format the result
                            let rolls_str = rolls
                                .iter()
                                .map(|r| r.to_string())
                                .collect::<Vec<_>>()
                                .join(", ");
                            
                            let result_message = format!(
                                "{} rolled {}d{}: [{}] = {}",
                                event.sender_id, count, sides, rolls_str, total
                            );
                            
                            // Send the message
                            send_chat_message(
                                &result_message,
                                "Dice Roller",
                                MessageType::System,
                                event.channel,
                                &mut message_events,
                            );
                        } else {
                            // Invalid dice parameters
                            send_chat_message(
                                &format!(
                                    "Invalid dice parameters. Maximum {} dice with {} sides.",
                                    config.max_dice, config.max_sides
                                ),
                                "Dice Roller",
                                MessageType::Error,
                                event.channel,
                                &mut message_events,
                            );
                        }
                    }
                }
            }
        }
    }
}
}

Voice Effect Plugin

A plugin that adds voice effects:

#![allow(unused)]
fn main() {
/// Voice effect plugin
pub struct VoiceEffectPlugin;

impl Plugin for VoiceEffectPlugin {
    fn build(&self, app: &mut App) {
        app.init_resource::<VoiceEffectSettings>()
           .add_systems(Startup, setup_voice_effect_ui)
           .add_systems(Update, (
               process_voice_effects,
               update_effect_settings,
           ));
    }
}

/// Voice effect settings
#[derive(Resource)]
struct VoiceEffectSettings {
    active_effect: Option<VoiceEffectType>,
    pitch_shift: f32,
    reverb_amount: f32,
    distortion: f32,
}

/// Voice effect types
enum VoiceEffectType {
    None,
    HighPitch,
    LowPitch,
    Robot,
    Echo,
    Custom,
}

/// System to process voice with effects
fn process_voice_effects(
    mut voice_events: EventReader<VoicePacketEvent>,
    mut processed_voice_events: EventWriter<VoicePacketEvent>,
    settings: Res<VoiceEffectSettings>,
    local_player: Res<LocalPlayer>,
) {
    // Only process local player's voice
    for event in voice_events.read() {
        if event.player_id != local_player.id {
            // Forward other players' packets unchanged
            processed_voice_events.send(event.clone());
            continue;
        }
        
        // Skip processing if no effect is active
        if settings.active_effect == Some(VoiceEffectType::None) || settings.active_effect.is_none() {
            processed_voice_events.send(event.clone());
            continue;
        }
        
        // Decompress audio data
        let decompressed = decompress_audio_data(&event.audio_data);
        
        // Apply selected effect
        let processed_audio = match settings.active_effect {
            Some(VoiceEffectType::HighPitch) => apply_pitch_shift(&decompressed, 1.5),
            Some(VoiceEffectType::LowPitch) => apply_pitch_shift(&decompressed, 0.7),
            Some(VoiceEffectType::Robot) => apply_robot_effect(&decompressed),
            Some(VoiceEffectType::Echo) => apply_echo_effect(&decompressed, 0.5, 0.3),
            Some(VoiceEffectType::Custom) => apply_custom_effect(
                &decompressed, 
                settings.pitch_shift, 
                settings.reverb_amount, 
                settings.distortion
            ),
            _ => decompressed,
        };
        
        // Compress processed audio
        let compressed = compress_audio_data(&processed_audio);
        
        // Send modified packet
        processed_voice_events.send(VoicePacketEvent {
            player_id: event.player_id,
            audio_data: compressed,
            sequence_number: event.sequence_number,
            timestamp: event.timestamp,
            channel: event.channel,
        });
    }
}
}

Best Practices

Performance Considerations

  • Minimize processing in chat message handling systems
  • Use efficient algorithms for text processing
  • Cache results of expensive operations
  • For voice processing, limit complexity when multiple players are speaking
  • Clean up resources when plugins are disabled

Compatibility

  • Follow the chat message formatting conventions
  • Don't override default commands without good reason
  • Provide fallback behavior when your plugin's features are disabled
  • Test interactions with other popular plugins

User Experience

  • Make plugin features discoverable through chat help commands
  • Provide clear feedback for user actions
  • Allow users to disable or customize plugin features
  • Don't spam the chat with too many messages
  • Respect user privacy settings with voice processing

Error Handling

  • Validate all user input before processing
  • Provide helpful error messages for invalid commands
  • Catch and log exceptions rather than crashing
  • Gracefully handle missing resources or components
  • Have fallback behavior when network operations fail

Chat System Network Protocol

This document details the network protocol used by Rummage's in-game chat system for transmitting text and voice data between players.

Table of Contents

  1. Overview
  2. Network Architecture
  3. Text Chat Protocol
  4. Voice Chat Protocol
  5. Security Considerations
  6. Bandwidth Optimization
  7. Implementation Details
  8. Error Handling

Overview

The chat network protocol is designed to efficiently transmit both text and voice data between players in a Commander game. It prioritizes low latency for real-time communication while maintaining bandwidth efficiency and reliability.

Key design principles:

  • Efficiency: Minimize bandwidth usage without sacrificing quality
  • Reliability: Ensure delivery of text messages while optimizing voice transmission
  • Security: Protect player communication from tampering and eavesdropping
  • Scalability: Support multiple players with varying network conditions
  • Extensibility: Allow for future protocol extensions and enhancements

Network Architecture

Communication Model

The chat system uses a hybrid networking model:

  • Peer-to-Peer Mode: Direct connections between players for lower latency (default for voice)
  • Client-Server Mode: Messages routed through a central server (default for text)
  • Relay Mode: Fallback when direct connections aren't possible due to NAT/firewalls

Connection Flow

  1. Initialization: When joining a game, players establish communication channels
  2. Channel Negotiation: Determine optimal connection type for each player pair
  3. Session Establishment: Create encrypted sessions for text and voice
  4. Heartbeat Monitoring: Regular connectivity checks to detect disconnections
  5. Graceful Termination: Proper session closure when leaving a game

Protocol Layers

The network protocol is organized in layers:

┌─────────────────────────────────────┐
│ Application Layer (Chat UI/Logic)   │
├─────────────────────────────────────┤
│ Message Layer (Serialization/Types) │
├─────────────────────────────────────┤
│ Session Layer (Encryption/Auth)     │
├─────────────────────────────────────┤
│ Transport Layer (UDP/TCP)           │
└─────────────────────────────────────┘
  • Transport Layer: Uses TCP for text messages and UDP for voice data
  • Session Layer: Handles encryption, authentication, and session management
  • Message Layer: Serializes/deserializes messages and handles message types
  • Application Layer: Processes messages for display and user interaction

Text Chat Protocol

Message Format

Text messages use a binary format with the following structure:

┌────────┬────────┬───────────┬──────────┬─────────┬────────────┐
│ Header │ Length │ Timestamp │ Metadata │ Payload │ Checksum   │
│ (8B)   │ (4B)   │ (8B)      │ (Variable)│ (Variable)│ (4B)      │
└────────┴────────┴───────────┴──────────┴─────────┴────────────┘

Header (8 bytes)

┌───────────────┬─────────────┬───────────┬───────────┬───────────┐
│ Protocol Ver. │ Message Type│ Channel ID│ Sender ID │ Flags     │
│ (1B)          │ (1B)        │ (2B)      │ (2B)      │ (2B)      │
└───────────────┴─────────────┴───────────┴───────────┴───────────┘
  • Protocol Version: Current protocol version (currently 1)
  • Message Type: Type of message (standard, system, command, etc.)
  • Channel ID: Channel identifier for routing (global, team, private, etc.)
  • Sender ID: Unique ID of the sender
  • Flags: Special message flags (e.g., encrypted, acknowledged, etc.)

Length (4 bytes)

Total message length in bytes, including header and checksum.

Timestamp (8 bytes)

Unix timestamp with millisecond precision for message ordering and latency calculation.

Metadata (Variable)

Optional metadata fields depending on message type:

  • For standard messages: formatting options, reply references, etc.
  • For system messages: event types, severity levels, etc.
  • For command messages: command identifiers, argument count, etc.

Payload (Variable)

The actual message content, UTF-8 encoded text. For binary data (e.g., emotes, images), a specialized binary format is used with appropriate type headers.

Checksum (4 bytes)

CRC32 checksum for message integrity verification.

Message Types

The protocol supports various message types:

Type IDNameDescription
0x01STANDARDRegular user message
0x02SYSTEMSystem message or notification
0x03COMMANDChat command
0x04EMOTEEmote action
0x05REACTIONMessage reaction
0x06STATUSUser status change
0x07READ_RECEIPTMessage read confirmation
0x08TYPINGTyping indicator
0x09BINARYBinary data (images, etc.)
0x0ACONTROLProtocol control message
0x0BVOICE_CONTROLVoice chat control

Flow Control

The text chat protocol implements flow control mechanisms:

  • Rate Limiting: Maximum messages per second (configurable per channel)
  • Message Ordering: Sequence numbers to handle out-of-order delivery
  • Message Acknowledgment: Optional ACKs for important messages
  • Retry Logic: Automatic retransmission of unacknowledged messages
  • Flood Protection: Dynamic rate limiting based on channel activity

Serialization

Message serialization follows these steps:

  1. Create message object with all required fields
  2. Serialize message to binary format with appropriate headers
  3. Apply compression if message exceeds threshold size
  4. Encrypt message content if required
  5. Calculate and append checksum
  6. Transmit over appropriate transport (TCP or WebSocket)

Voice Chat Protocol

Packet Format

Voice data uses a compact binary format optimized for real-time transmission:

┌────────┬────────┬───────────┬──────────┬─────────┬────────────┐
│ Header │ Sequence│ Timestamp │ Channel  │ Payload │ Checksum   │
│ (4B)   │ (2B)    │ (4B)      │ (1B)     │ (Variable)│ (2B)      │
└────────┴────────┴───────────┴──────────┴─────────┴────────────┘

Header (4 bytes)

┌───────────────┬─────────────┬───────────┬───────────┐
│ Protocol Ver. │ Coding Type │ Player ID │ Flags     │
│ (1B)          │ (1B)        │ (1B)      │ (1B)      │
└───────────────┴─────────────┴───────────┴───────────┘
  • Protocol Version: Current voice protocol version
  • Coding Type: Audio codec and parameters
  • Player ID: Unique ID of the speaking player
  • Flags: Special flags (e.g., priority speaker, muted, etc.)

Sequence Number (2 bytes)

Sequential packet counter for detecting packet loss and handling jitter.

Timestamp (4 bytes)

Relative timestamp in milliseconds for synchronization and jitter buffer management.

Channel (1 byte)

Voice channel identifier (global, team, private, etc.).

Payload (Variable)

Compressed audio data using the specified codec. Typical payload sizes:

  • Opus codec at 20ms frames: ~80-120 bytes per frame
  • Range: 10-120 bytes depending on codec and settings

Checksum (2 bytes)

CRC16 checksum for basic packet integrity verification.

Audio Encoding

The voice chat protocol supports multiple audio codecs:

IDCodecBit RateSample RateFrame Size
0x01Opus16-64 kbps48 kHz20ms
0x02Opus8-24 kbps24 kHz20ms
0x03Opus6-12 kbps16 kHz20ms
0x04Speex8-16 kbps16 kHz20ms
0x05Celt16-32 kbps32 kHz20ms

Codec selection is automatic based on:

  • Available bandwidth
  • CPU capabilities
  • User quality preferences
  • Network conditions

Network Optimization

The voice protocol includes several optimizations:

  • Jitter Buffer: Dynamic buffer to handle network timing variations
  • Packet Loss Concealment: Interpolation of missing audio frames
  • FEC (Forward Error Correction): Optional redundancy for high-quality mode
  • Dynamic Bitrate Adjustment: Adapts to changing network conditions
  • Silence Suppression: Reduced data during silence periods
  • Prioritization: QoS markings for voice packets

Voice Activity Detection

The protocol uses voice activity detection to minimize bandwidth:

  1. Client-side detection determines when player is speaking
  2. Only active voice is transmitted
  3. Comfort noise is generated during silence
  4. Brief transmission continues after speech ends to avoid clipping

Security Considerations

Encryption

All chat communication is encrypted using:

  • Transport Security: TLS 1.3 for WebSocket/TCP connections
  • Content Encryption: AES-256-GCM for message payloads
  • Key Exchange: ECDHE for perfect forward secrecy
  • Authentication: HMAC-SHA256 for message authentication

Authentication

Messages are authenticated using:

  • Session Keys: Generated during game join process
  • Message Signing: HMAC signature for each message
  • Player Identity: Verified through game server authentication
  • Anti-Spoofing: Measures to prevent player impersonation

Privacy Controls

The protocol implements privacy features:

  • Muting: Client-side and server-side muting options
  • Blocking: Prevent communication from specific players
  • Reporting: Ability to report abusive messages with context
  • Data Minimization: Limited metadata collection
  • Retention Policy: Temporary storage of message history

Bandwidth Optimization

Text Chat Optimization

Text messages use several bandwidth optimization techniques:

  • Message Batching: Combine multiple messages when possible
  • Compression: zlib compression for larger messages
  • Differential Updates: Send only changes for edited messages
  • Efficient Encoding: Binary format instead of JSON/XML
  • Incremental History: Download message history in chunks

Voice Chat Optimization

Voice transmission is optimized for bandwidth efficiency:

  • Codec Selection: Choose appropriate codec based on conditions
  • Bitrate Adaptation: Adjust quality based on available bandwidth
  • Packet Coalescing: Combine small packets when possible
  • Selective Forwarding: Only send voice to players who need it
  • Bandwidth Limiter: Cap total voice bandwidth usage
  • Mixed-mode: Option to route voice through server when P2P is inefficient

Bandwidth Usage

Typical bandwidth usage per player:

Communication TypeDirectionBandwidth (Average)Bandwidth (Peak)
Text ChatUpload0.1-0.5 KB/s2-5 KB/s
Text ChatDownload0.2-1.0 KB/s5-10 KB/s
Voice ChatUpload5-15 KB/s20 KB/s
Voice ChatDownload5-15 KB/s per active speaker20 KB/s per speaker

Implementation Details

Protocol Handlers

The protocol is implemented using Bevy's ECS architecture:

#![allow(unused)]
fn main() {
/// System to handle incoming chat network packets
fn handle_chat_network_packets(
    mut network_receiver: EventReader<NetworkPacketReceived>,
    mut message_events: EventWriter<ChatMessageEvent>,
    session_manager: Res<ChatSessionManager>,
) {
    for packet in network_receiver.read() {
        if packet.protocol_id != CHAT_PROTOCOL_ID {
            continue;
        }
        
        // Verify packet integrity
        if !verify_packet_checksum(&packet.data) {
            // Log error and discard packet
            continue;
        }
        
        // Decrypt message
        let decrypted_data = match decrypt_message(
            &packet.data,
            &session_manager.get_session_key(packet.sender_id),
        ) {
            Ok(data) => data,
            Err(e) => {
                // Log decryption error
                continue;
            }
        };
        
        // Deserialize message
        let message = match deserialize_chat_message(&decrypted_data) {
            Ok(msg) => msg,
            Err(e) => {
                // Log deserialization error
                continue;
            }
        };
        
        // Process message
        message_events.send(ChatMessageEvent {
            message,
            recipients: get_recipients_from_channel(message.channel),
        });
    }
}

/// System to send chat messages over the network
fn send_chat_network_packets(
    mut message_events: EventReader<OutgoingChatMessageEvent>,
    mut network_sender: EventWriter<NetworkPacketSend>,
    session_manager: Res<ChatSessionManager>,
) {
    for event in message_events.read() {
        // Serialize message
        let serialized_data = match serialize_chat_message(&event.message) {
            Ok(data) => data,
            Err(e) => {
                // Log serialization error
                continue;
            }
        };
        
        // Encrypt message for each recipient
        for recipient_id in get_recipient_ids(&event.recipients) {
            let session_key = session_manager.get_session_key(recipient_id);
            
            let encrypted_data = match encrypt_message(&serialized_data, &session_key) {
                Ok(data) => data,
                Err(e) => {
                    // Log encryption error
                    continue;
                }
            };
            
            // Add checksum
            let final_data = add_checksum(&encrypted_data);
            
            // Send packet
            network_sender.send(NetworkPacketSend {
                protocol_id: CHAT_PROTOCOL_ID,
                recipient_id,
                data: final_data,
                reliability: PacketReliability::Reliable,
                channel: NetworkChannel::Chat,
            });
        }
    }
}
}

Voice Processing Pipeline

The voice chat processing pipeline:

#![allow(unused)]
fn main() {
/// System to process and send voice data
fn process_and_send_voice(
    mut audio_input: ResMut<AudioInputBuffer>,
    mut voice_events: EventWriter<OutgoingVoicePacketEvent>,
    voice_config: Res<VoiceChatConfig>,
    voice_activity: Res<VoiceActivityDetector>,
    encoder: Res<AudioEncoder>,
    local_player: Res<LocalPlayer>,
    time: Res<Time>,
) {
    // Check if player is muted or voice is disabled
    if !voice_config.enabled || voice_config.is_muted {
        return;
    }
    
    // Get audio samples from input buffer
    let raw_samples = audio_input.get_samples(FRAME_SIZE);
    
    // Check for voice activity
    let is_voice_active = voice_config.activation_mode == VoiceActivationMode::AlwaysOn ||
                         (voice_config.activation_mode == VoiceActivationMode::VoiceActivated && 
                          voice_activity.detect_voice(&raw_samples));
    
    if is_voice_active {
        // Apply preprocessing (noise suppression, etc.)
        let processed_samples = preprocess_audio(
            &raw_samples,
            voice_config.noise_suppression_level,
            voice_config.auto_gain_control,
        );
        
        // Encode audio with selected codec
        let encoded_data = encoder.encode(&processed_samples);
        
        // Determine sequence number
        let sequence = next_voice_sequence_number();
        
        // Create voice packet
        let voice_packet = VoicePacket {
            player_id: local_player.id,
            sequence,
            timestamp: time.elapsed_seconds() * 1000.0,
            channel: voice_config.active_channel,
            data: encoded_data,
        };
        
        // Send to network system
        voice_events.send(OutgoingVoicePacketEvent {
            packet: voice_packet,
        });
    }
}
}

Protocol Constants

Key protocol constants:

#![allow(unused)]
fn main() {
// Protocol identifiers
const CHAT_PROTOCOL_ID: u8 = 0x01;
const VOICE_PROTOCOL_ID: u8 = 0x02;

// Protocol versions
const TEXT_PROTOCOL_VERSION: u8 = 0x01;
const VOICE_PROTOCOL_VERSION: u8 = 0x01;

// Maximum message sizes
const MAX_TEXT_MESSAGE_SIZE: usize = 2048;
const MAX_VOICE_PACKET_SIZE: usize = 512;

// Timing constants
const TEXT_RETRY_INTERVAL_MS: u32 = 500;
const MAX_TEXT_RETRIES: u32 = 5;
const VOICE_FRAME_DURATION_MS: u32 = 20;
const MAX_JITTER_BUFFER_MS: u32 = 200;

// Rate limiting
const MAX_MESSAGES_PER_SECOND: u32 = 5;
const MAX_VOICE_BANDWIDTH_KBPS: u32 = 30;
}

Error Handling

Network Errors

The protocol handles various network errors:

  • Connection Loss: Automatic reconnection with exponential backoff
  • Packet Loss: Retransmission for text, concealment for voice
  • Latency Spikes: Jitter buffer for voice, acknowledge timeouts for text
  • Fragmentation: Message reassembly for large text messages
  • MTU Limits: Automatic fragmentation for oversized packets

Protocol Errors

Handling of protocol-level errors:

  • Version Mismatch: Negotiation of compatible protocol version
  • Malformed Messages: Proper error logging and discarding
  • Checksum Failures: Retransmission requests for critical messages
  • Decryption Failures: Session renegotiation if persistent
  • Sequence Gaps: Reordering or retransmission as appropriate

Error Reporting

Error metrics collected for monitoring:

  • Packet Loss Rate: Percentage of packets not received
  • Retry Rate: Frequency of message retransmissions
  • Decryption Failures: Count of failed decryption attempts
  • Latency: Round-trip time for acknowledgments
  • Jitter: Variance in packet arrival times

These metrics are used to dynamically adjust protocol parameters for optimal performance.

In-Game Text Chat System

This document provides detailed information about the text chat component of Rummage's in-game communication system.

Table of Contents

  1. Overview
  2. UI Components
  3. Text Chat Features
  4. Message Types
  5. Chat Commands
  6. Implementation Details
  7. Testing

Overview

The text chat system provides a flexible and intuitive interface for players to communicate during Commander games. It balances ease of use with powerful features, allowing for both casual conversation and game-specific communication.

Key design principles:

  • Non-Intrusive: Minimizes screen space usage while maintaining readability
  • Context-Aware: Adapts to game state and player actions
  • Flexible: Supports various communication needs and styles
  • Integrated: Closely tied to game mechanics and events

UI Components

The text chat interface consists of several key components:

Chat Window

The main container for all chat-related UI elements:

┌────────────────────────────────────────────┐
│ [Global] [Team] [Spectators] [Settings] [X]│
├────────────────────────────────────────────┤
│ [System] Game started                      │
│ Player1: Hello everyone                    │
│ Player2: Good luck & have fun!             │
│ [Event] Player3 casts Lightning Bolt       │
│                                            │
│                                            │
│                                            │
│                                            │
├────────────────────────────────────────────┤
│ Type message...                 [Send] [📢]│
└────────────────────────────────────────────┘
  • Header: Channel tabs, settings button, close/minimize button
  • Message Display: Scrollable area showing chat history
  • Input Field: Text entry area with send button and voice chat toggle

Notification Badge

When the chat is minimized, notifications appear showing new messages:

┌───────┐
│ Chat 3│
└───────┘

The badge shows the number of unread messages and changes color based on message importance.

Message Bubbles

Individual messages are displayed in styled bubbles with context information:

┌─────────────────────────────────────────┐
│ Player1 (10:42):                        │
│ Does anyone have a response to this?    │
└─────────────────────────────────────────┘

Each message includes:

  • Sender name with optional player color
  • Timestamp
  • Message content with optional formatting
  • Message type indicator (system, event, etc.)

Text Chat Features

Chat Channels

The system supports multiple communication channels:

  • Global: Messages visible to all players in the game
  • Team: Messages visible only to teammates (for team games)
  • Private: Direct messages to specific players
  • Spectator: Communication with non-playing observers
  • System: Game information and event messages
  • Event Log: Detailed game action history

Users can switch between channels using tabs or chat commands.

Message Formatting

The chat supports basic text formatting options:

  • Emphasis: italic and bold text
  • Card References: [[Card Name]] auto-links to card information
  • Links: Clickable URLs with preview tooltips
  • Emoji: Standard emoji support with game-specific additions
  • Color Coding: Optional colored text based on message type or sender

Message Filtering

Users can filter the chat display based on various criteria:

  • Channel Filters: Show/hide messages from specific channels
  • Type Filters: Show/hide system messages, events, etc.
  • Player Filters: Focus on messages from specific players
  • Keyword Filters: Highlight or hide messages containing specific terms

Chat History

The chat system maintains a searchable history of messages:

  • Scrollback: Browse previous messages within the current session
  • Search: Find messages containing specific text or from specific players
  • Session Log: Option to save chat history to a file
  • Persistence: Optional storage of chat logs between sessions

Message Types

The chat system supports several types of messages:

Player Messages

Standard text messages sent by players:

  • Chat Messages: Normal conversation text
  • Announcements: Important player notifications
  • Responses: Contextual replies to other messages or game events

System Messages

Automated messages from the game system:

  • Game Events: Card plays, battlefield changes, life total updates
  • Phase Updates: Turn phase transitions, priority changes
  • Timer Notifications: Round time, turn time warnings
  • Game Status: Win conditions, player eliminations, etc.

Command Messages

Special messages that trigger game actions or chat functions:

  • Chat Commands: Messages starting with / that invoke special functions
  • Quick Commands: Pre-defined messages accessible through hotkeys
  • Emote Commands: Text triggers for emote animations

Chat Commands

The chat system includes command functionality for quick actions:

Core Commands

Essential commands available to all players:

  • /help: Display available commands
  • /msg [player] [text]: Send private message
  • /clear: Clear chat history
  • /mute [player]: Mute specified player
  • /unmute [player]: Unmute specified player

Game Commands

Commands that provide game information:

  • /life [player]: Show player's current life total
  • /card [card name]: Display card information
  • /hand: Show number of cards in each player's hand
  • /graveyard [player]: List cards in player's graveyard

Social Commands

Commands for social interaction:

  • /emote [emote name]: Display an emote
  • /roll [X]d[Y]: Roll X Y-sided dice
  • /flip: Flip a coin
  • /timer [seconds]: Set a countdown timer

Implementation Details

The text chat is implemented using Bevy's ECS architecture:

Data Structures

#![allow(unused)]
fn main() {
/// Represents a single chat message
#[derive(Clone, Debug)]
struct ChatMessage {
    sender_id: Option<PlayerId>,
    sender_name: String,
    content: String,
    timestamp: f64,
    channel: ChatChannel,
    message_type: MessageType,
    formatting: Option<MessageFormatting>,
}

/// Defines available chat channels
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
enum ChatChannel {
    Global,
    Team,
    Private(PlayerId),
    Spectator,
    System,
    EventLog,
}

/// Defines message types for styling and filtering
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum MessageType {
    Normal,
    System,
    Event,
    Error,
    Whisper,
    Command,
    Emote,
}

/// Event for chat message transmission
#[derive(Event)]
struct ChatMessageEvent {
    message: ChatMessage,
    recipients: MessageRecipients,
}

/// Defines message recipients
#[derive(Clone, Debug)]
enum MessageRecipients {
    All,
    Team(TeamId),
    Player(PlayerId),
    Spectators,
}
}

Components

#![allow(unused)]
fn main() {
/// Component for the chat message display area
#[derive(Component)]
struct ChatMessageArea {
    visible_channels: HashSet<ChatChannel>,
    filter_settings: ChatFilterSettings,
    max_messages: usize,
    scroll_position: f32,
}

/// Component for the chat input field
#[derive(Component)]
struct ChatInputField {
    text: String,
    cursor_position: usize,
    history: Vec<String>,
    history_position: Option<usize>,
    target_channel: ChatChannel,
}

/// Component for individual message entities
#[derive(Component)]
struct ChatMessageEntity {
    message: ChatMessage,
    is_read: bool,
    animation_state: MessageAnimationState,
}
}

Message Rendering System

#![allow(unused)]
fn main() {
/// System to render chat messages
fn render_chat_messages(
    mut commands: Commands,
    mut message_area_query: Query<(&mut ChatMessageArea, &Children)>,
    message_query: Query<(Entity, &ChatMessageEntity)>,
    message_events: EventReader<ChatMessageEvent>,
    chat_settings: Res<ChatSettings>,
    asset_server: Res<AssetServer>,
    time: Res<Time>,
) {
    // Process new messages
    for event in message_events.read() {
        for (mut message_area, children) in &mut message_area_query {
            // Check if message should be displayed in this area
            if !message_area.visible_channels.contains(&event.message.channel) {
                continue;
            }
            
            // Apply filters
            if !passes_filters(&event.message, &message_area.filter_settings) {
                continue;
            }
            
            // Create new message entity
            let message_entity = spawn_message_entity(
                &mut commands, 
                &event.message, 
                &asset_server, 
                &chat_settings
            );
            
            // Add to message area
            commands.entity(message_area_entity).add_child(message_entity);
            
            // Limit number of messages
            if children.len() > message_area.max_messages {
                // Remove oldest message
                if let Some(oldest) = children.first() {
                    commands.entity(*oldest).despawn_recursive();
                }
            }
            
            // Auto-scroll to new message
            message_area.scroll_position = 1.0;
        }
    }
    
    // Update message animations
    for (entity, message) in &message_query {
        // Update animation state based on time
        // ...
    }
}
}

Chat Input System

#![allow(unused)]
fn main() {
/// System to handle chat input
fn handle_chat_input(
    mut commands: Commands,
    mut input_query: Query<&mut ChatInputField>,
    keyboard_input: Res<ButtonInput<KeyCode>>,
    mut chat_events: EventWriter<ChatMessageEvent>,
    player_query: Query<(&Player, &PlayerId)>,
    time: Res<Time>,
) {
    for mut input_field in &mut input_query {
        // Check for Enter key to send message
        if keyboard_input.just_pressed(KeyCode::Return) {
            if !input_field.text.is_empty() {
                // Create message from input
                let message = create_message_from_input(
                    &input_field.text,
                    &player_query,
                    input_field.target_channel,
                    time.elapsed_seconds(),
                );
                
                // Handle commands
                if input_field.text.starts_with('/') {
                    process_command(&input_field.text, &mut chat_events);
                } else {
                    // Send regular message
                    chat_events.send(ChatMessageEvent {
                        message,
                        recipients: get_recipients_for_channel(input_field.target_channel),
                    });
                }
                
                // Add to history and clear input
                input_field.history.push(input_field.text.clone());
                input_field.text.clear();
                input_field.cursor_position = 0;
                input_field.history_position = None;
            }
        }
        
        // Handle Up/Down for history navigation
        if keyboard_input.just_pressed(KeyCode::Up) {
            navigate_history_up(&mut input_field);
        }
        if keyboard_input.just_pressed(KeyCode::Down) {
            navigate_history_down(&mut input_field);
        }
        
        // Handle Tab for channel switching
        if keyboard_input.just_pressed(KeyCode::Tab) {
            cycle_chat_channel(&mut input_field);
        }
    }
}
}

Testing

The text chat component requires thorough testing:

Unit Tests

#![allow(unused)]
fn main() {
#[test]
fn test_message_filtering() {
    // Create test app
    let mut app = App::new();
    app.add_plugins(MinimalPlugins)
       .add_event::<ChatMessageEvent>()
       .add_systems(Update, chat_systems::filter_messages);
    
    // Setup test state
    let filter_settings = ChatFilterSettings {
        show_system_messages: false,
        show_event_messages: true,
        // ...
    };
    
    app.world.insert_resource(filter_settings);
    
    // Create test messages
    let system_message = ChatMessage {
        message_type: MessageType::System,
        // ...
    };
    
    let event_message = ChatMessage {
        message_type: MessageType::Event,
        // ...
    };
    
    // Send test messages
    app.world.send_event(ChatMessageEvent {
        message: system_message,
        recipients: MessageRecipients::All,
    });
    
    app.world.send_event(ChatMessageEvent {
        message: event_message,
        recipients: MessageRecipients::All,
    });
    
    // Run systems
    app.update();
    
    // Verify filtering
    let visible_messages = app.world.query::<&ChatMessageEntity>().iter(&app.world).collect::<Vec<_>>();
    
    assert_eq!(visible_messages.len(), 1, "Only event message should be visible");
    assert_eq!(visible_messages[0].message.message_type, MessageType::Event);
}
}

Integration Tests

#![allow(unused)]
fn main() {
#[test]
fn test_chat_command_processing() {
    // Create test app with necessary plugins
    let mut app = App::new();
    app.add_plugins(GameUiTestPlugins)
       .add_systems(Update, (
           chat_systems::handle_chat_input,
           chat_systems::process_commands,
       ));
    
    // Setup test game with players
    setup_test_game(&mut app, 2);
    
    // Get chat input entity
    let input_entity = app.world.query_filtered::<Entity, With<ChatInputField>>().single(&app.world);
    
    // Simulate typing a command
    let mut input_field = app.world.get_mut::<ChatInputField>(input_entity).unwrap();
    input_field.text = "/roll 2d6".to_string();
    
    // Simulate Enter key press
    app.world.send_event(KeyboardInput {
        key: KeyCode::Return,
        state: ButtonState::Pressed,
    });
    
    // Run systems
    app.update();
    
    // Verify command was processed
    let messages = app.world.query::<&ChatMessageEntity>().iter(&app.world).collect::<Vec<_>>();
    
    // Should have a system message with dice roll results
    assert!(messages.iter().any(|m| 
        m.message.message_type == MessageType::System && 
        m.message.content.contains("rolled 2d6")
    ), "Dice roll command should create system message");
}
}

Performance Tests

#![allow(unused)]
fn main() {
#[test]
fn test_chat_performance_with_many_messages() {
    // Create test app
    let mut app = App::new();
    app.add_plugins(GameUiTestPlugins)
       .add_plugins(DiagnosticsPlugin);
    
    // Setup chat window
    let chat_entity = app.world.spawn((
        ChatWindow { /* ... */ },
        ChatMessageArea {
            max_messages: 100,
            // ...
        },
        // ...
    )).id();
    
    // Generate many test messages
    let messages = (0..500).map(|i| ChatMessage {
        content: format!("Test message {}", i),
        // ...
    }).collect::<Vec<_>>();
    
    // Measure performance while adding messages
    let mut frame_times = Vec::new();
    for message in messages {
        app.world.send_event(ChatMessageEvent {
            message,
            recipients: MessageRecipients::All,
        });
        
        let start = std::time::Instant::now();
        app.update();
        frame_times.push(start.elapsed());
    }
    
    // Calculate average frame time
    let avg_frame_time = frame_times.iter().sum::<std::time::Duration>() / frame_times.len() as u32;
    
    // Ensure performance remains acceptable
    assert!(avg_frame_time.as_millis() < 16, "Chat should maintain 60+ FPS with many messages");
}
}

In-Game Voice Chat System

This document provides detailed information about the voice chat component of Rummage's in-game communication system.

Table of Contents

  1. Overview
  2. UI Components
  3. Core Features
  4. Audio Controls
  5. Integration With Game State
  6. Implementation Details
  7. Testing

Overview

The voice chat system provides real-time audio communication between players during Commander games. It enhances the social experience of playing Magic: The Gathering remotely by adding a layer of direct communication that complements the text chat system.

Key design principles:

  • Low Latency: Prioritizes minimal delay for natural conversation
  • Clarity: Emphasizes audio quality for clear communication
  • Accessibility: Provides alternative options for those who can't use voice
  • Non-Intrusive: Integrates with gameplay without disruption
  • Resource Efficient: Minimizes performance impact

UI Components

The voice chat interface consists of several components that integrate with the main chat UI:

Voice Activation Controls

┌─────────────────────────────┐
│ [🎤] Push to Talk [⚙️] [📊] │
└─────────────────────────────┘
  • Microphone Button: Toggle for enabling/disabling voice input
  • Mode Selector: Switch between Push-to-Talk and Voice Activation
  • Settings Button: Quick access to voice settings
  • Voice Level Indicator: Shows current microphone input level

Speaker Status Indicator

┌──────────────────────────────────┐
│ Player1 [🔊] Player2 [🔊] [🔇]   │
└──────────────────────────────────┘
  • Player List: Shows all players in the game
  • Speaker Icon: Animated icon showing who is currently speaking
  • Volume Controls: Per-player volume adjustment
  • Mute Button: Quick toggle to mute all voice chat

Voice Settings Panel

┌───────────────────────────────────────┐
│ Voice Chat Settings             [X]   │
├───────────────────────────────────────┤
│ Input Device: [Microphone▼]           │
│ Output Device: [Speakers▼]            │
│                                       │
│ Input Volume: [==========] 80%        │
│ Output Volume: [========--] 60%       │
│                                       │
│ Voice Activation Level: [===-------]  │
│                                       │
│ [✓] Noise Suppression                 │
│ [✓] Echo Cancellation                 │
│ [ ] Automatically Adjust Levels       │
│                                       │
│ Push-to-Talk Key: [Space]             │
│                                       │
│ [Reset to Defaults]     [Apply]       │
└───────────────────────────────────────┘
  • Device Selection: Input and output device configuration
  • Volume Controls: Master volume adjustments
  • Activation Settings: Voice detection sensitivity
  • Audio Processing: Noise suppression and echo cancellation options
  • Key Bindings: Configure Push-to-Talk keys

Voice Activity Indicators

Visual cues integrated into player avatars:

  • Glowing Border: Indicates a player is speaking
  • Volume Level: Shows relative volume of speaking player
  • Mute Indicator: Shows when a player is muted or has muted themselves

Core Features

Voice Activation Modes

The system supports multiple ways to activate voice transmission:

  • Push-to-Talk: Requires holding a key to transmit voice
  • Voice Activation: Automatically transmits when speech is detected
  • Always On: Continuously transmits audio (with optional noise gate)
  • Priority Speaker: Option for game host to override other speakers

Audio Quality Settings

Configurable audio quality options:

  • Quality Presets: Low, Medium, High, and Ultra profiles
  • Bandwidth Control: Automatic adjustment based on network conditions
  • Sample Rate: Options from 16 kHz to 48 kHz
  • Bit Depth: 16-bit or 24-bit audio

Channel Management

Support for multiple audio channels:

  • Global Voice: Heard by all players in the game
  • Team Voice: Private channel for team members in team games
  • Private Call: One-on-one communication between specific players
  • Spectator Channel: Communication with non-playing observers

Audio Controls

Input Controls

Options for managing voice input:

  • Microphone Selection: Choose between available input devices
  • Input Gain: Adjust microphone sensitivity
  • Noise Gate: Filter out background noise below threshold
  • Push-to-Talk Delay: Set release timing to avoid cutting off words
  • Automatic Gain Control: Maintain consistent input volume

Output Controls

Options for managing voice output:

  • Speaker Selection: Choose between available output devices
  • Master Volume: Overall voice chat volume control
  • Per-Player Volume: Individual volume controls for each player
  • Audio Panning: Spatial positioning of voices (disabled by default)
  • Ducking: Option to reduce game sound when others are speaking

Audio Processing

Features for improving voice quality:

  • Noise Suppression: Reduce background noise
  • Echo Cancellation: Prevent feedback and echo
  • Voice Clarity Enhancement: Emphasis on speech frequencies
  • Automatic Level Adjustment: Balance volume between different players

Integration With Game State

The voice chat system adapts to the current game state:

Game Phase Integration

  • Planning Phase: Normal voice operation
  • Active Phase: Option to highlight speaking player's cards
  • End Phase: Notification sounds for voice chat

Player Status Integration

  • Disconnected Players: Automatic muting with indicator
  • AFK Detection: Automatic muting after period of inactivity
  • Priority Status: Visual indicator when speaking player has priority

Contextual Features

  • Positional Audio: Optional feature to position voices based on player's virtual position
  • Effect Filters: Special voice effects for certain game actions (disabled by default)
  • Auto-Mute: Option to mute voice during cinematic moments

Implementation Details

The voice chat is implemented using Bevy's ECS architecture with additional audio processing libraries:

Data Structures

#![allow(unused)]
fn main() {
/// Voice chat configuration
#[derive(Resource)]
struct VoiceChatConfig {
    enabled: bool,
    input_device_id: Option<String>,
    output_device_id: Option<String>,
    input_volume: f32,
    output_volume: f32,
    activation_mode: VoiceActivationMode,
    activation_threshold: f32,
    push_to_talk_key: KeyCode,
    noise_suppression_level: NoiseSuppressionLevel,
    echo_cancellation: bool,
    auto_gain_control: bool,
}

/// Voice activation modes
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum VoiceActivationMode {
    PushToTalk,
    VoiceActivated,
    AlwaysOn,
    Disabled,
}

/// Noise suppression levels
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum NoiseSuppressionLevel {
    Off,
    Low,
    Medium,
    High,
    Maximum,
}

/// Voice activity status for a player
#[derive(Component)]
struct VoiceActivityStatus {
    player_id: PlayerId,
    is_speaking: bool,
    is_muted: bool,
    is_muted_by_local: bool,
    volume_level: f32,
    peak_level: f32,
}

/// Voice chat audio buffer
#[derive(Resource)]
struct VoiceAudioBuffer {
    input_buffer: Vec<f32>,
    output_buffer: HashMap<PlayerId, Vec<f32>>,
    buffer_size: usize,
    sample_rate: u32,
}

/// Voice packet for network transmission
#[derive(Event)]
struct VoicePacketEvent {
    player_id: PlayerId,
    audio_data: Vec<u8>,
    sequence_number: u32,
    timestamp: f64,
    channel: VoiceChannel,
}
}

Components

#![allow(unused)]
fn main() {
/// Component for the voice chat UI controller
#[derive(Component)]
struct VoiceChatController {
    is_settings_open: bool,
    is_expanded: bool,
    active_channel: VoiceChannel,
}

/// Component for voice input indicators
#[derive(Component)]
struct VoiceInputIndicator {
    level: f32,
    speaking_confidence: f32,
    is_active: bool,
}

/// Component for player voice indicators
#[derive(Component)]
struct PlayerVoiceIndicator {
    player_id: PlayerId,
    is_speaking: bool,
}
}

Audio Capture System

#![allow(unused)]
fn main() {
/// System to capture and process microphone input
fn capture_microphone_input(
    mut audio_buffer: ResMut<VoiceAudioBuffer>,
    voice_config: Res<VoiceChatConfig>,
    keyboard_input: Res<ButtonInput<KeyCode>>,
    audio_input: Res<AudioInputDevice>,
    mut voice_events: EventWriter<VoicePacketEvent>,
    time: Res<Time>,
    local_player: Res<LocalPlayer>,
) {
    // Skip if voice chat is disabled
    if !voice_config.enabled {
        return;
    }
    
    // Check activation mode
    let should_capture = match voice_config.activation_mode {
        VoiceActivationMode::PushToTalk => {
            keyboard_input.pressed(voice_config.push_to_talk_key)
        },
        VoiceActivationMode::VoiceActivated => {
            // Detect voice using energy threshold
            let energy = calculate_audio_energy(&audio_input);
            energy > voice_config.activation_threshold
        },
        VoiceActivationMode::AlwaysOn => true,
        VoiceActivationMode::Disabled => false,
    };
    
    if should_capture {
        // Capture audio from microphone
        let input_samples = audio_input.capture_samples(audio_buffer.buffer_size);
        
        // Apply audio processing (noise suppression, etc.)
        let processed_samples = process_audio_samples(
            &input_samples, 
            voice_config.noise_suppression_level,
            voice_config.echo_cancellation,
        );
        
        // Compress audio for network transmission
        let compressed_data = compress_audio_data(&processed_samples);
        
        // Send voice packet
        voice_events.send(VoicePacketEvent {
            player_id: local_player.id,
            audio_data: compressed_data,
            sequence_number: next_sequence_number(),
            timestamp: time.elapsed_seconds(),
            channel: voice_config.active_channel,
        });
    }
}
}

Audio Playback System

#![allow(unused)]
fn main() {
/// System to play received voice audio
fn play_voice_audio(
    mut audio_buffer: ResMut<VoiceAudioBuffer>,
    voice_config: Res<VoiceChatConfig>,
    voice_status: Query<&VoiceActivityStatus>,
    mut voice_events: EventReader<VoicePacketEvent>,
    audio_output: Res<AudioOutputDevice>,
    mut player_indicators: Query<(&mut PlayerVoiceIndicator, &mut BackgroundColor)>,
) {
    // Process incoming voice packets
    for packet in voice_events.read() {
        // Skip packets if voice is disabled
        if !voice_config.enabled {
            continue;
        }
        
        // Skip packets from muted players
        if let Ok(status) = voice_status.get_component::<VoiceActivityStatus>(packet.player_id) {
            if status.is_muted || status.is_muted_by_local {
                continue;
            }
        }
        
        // Decompress audio data
        let decompressed_data = decompress_audio_data(&packet.audio_data);
        
        // Apply volume adjustment
        let volume_adjusted = adjust_volume(
            &decompressed_data,
            voice_config.output_volume * get_player_volume(packet.player_id),
        );
        
        // Add to output buffer
        audio_buffer.output_buffer.insert(packet.player_id, volume_adjusted);
        
        // Update speaking indicators
        for (mut indicator, mut background) in &mut player_indicators {
            if indicator.player_id == packet.player_id {
                indicator.is_speaking = true;
                // Change background to indicate speaking
                *background = BackgroundColor(Color::rgba(0.2, 0.8, 0.2, 0.5));
            }
        }
    }
    
    // Mix and play output buffer
    let mixed_output = mix_audio_channels(&audio_buffer.output_buffer);
    audio_output.play_samples(&mixed_output);
    
    // Reset output buffer
    audio_buffer.output_buffer.clear();
    
    // Reset speaking indicators after delay
    // This would typically be done with a timer system
    // Simplified for documentation purposes
}
}

Voice Chat UI Update System

#![allow(unused)]
fn main() {
/// System to update voice chat UI
fn update_voice_chat_ui(
    mut voice_controller_query: Query<(&mut VoiceChatController, &Children)>,
    mut indicator_query: Query<(&mut VoiceInputIndicator, &mut Node)>,
    voice_status_query: Query<&VoiceActivityStatus>,
    voice_config: Res<VoiceChatConfig>,
    audio_input: Res<AudioInputDevice>,
    keyboard_input: Res<ButtonInput<KeyCode>>,
) {
    for (mut controller, children) in &mut voice_controller_query {
        // Toggle settings panel
        if keyboard_input.just_pressed(KeyCode::F7) {
            controller.is_settings_open = !controller.is_settings_open;
        }
        
        // Update input indicators
        for (mut indicator, mut node) in &mut indicator_query {
            // Update microphone level indicator
            let current_level = if voice_config.enabled {
                calculate_audio_level(audio_input.get_level())
            } else {
                0.0
            };
            
            indicator.level = smooth_level_transition(indicator.level, current_level, 0.1);
            
            // Update indicator visual size based on level
            let indicator_height = Val::Px(20.0 + indicator.level * 30.0);
            if node.height != indicator_height {
                node.height = indicator_height;
            }
            
            // Update active state
            indicator.is_active = match voice_config.activation_mode {
                VoiceActivationMode::PushToTalk => {
                    keyboard_input.pressed(voice_config.push_to_talk_key)
                },
                VoiceActivationMode::VoiceActivated => {
                    indicator.level > voice_config.activation_threshold
                },
                VoiceActivationMode::AlwaysOn => true,
                VoiceActivationMode::Disabled => false,
            };
        }
    }
}
}

Testing

The voice chat component requires thorough testing:

Unit Tests

#![allow(unused)]
fn main() {
#[test]
fn test_voice_activation_detection() {
    // Create test app
    let mut app = App::new();
    app.add_plugins(MinimalPlugins)
       .add_systems(Update, voice_systems::detect_voice_activity);
    
    // Setup test resources
    app.insert_resource(VoiceChatConfig {
        activation_mode: VoiceActivationMode::VoiceActivated,
        activation_threshold: 0.05,
        // ...
    });
    
    // Create mock audio data
    let silent_audio = vec![0.01f32; 1024];
    let speaking_audio = vec![0.2f32; 1024];
    
    // Test silent audio
    app.world.insert_resource(MockAudioInput {
        samples: silent_audio.clone(),
    });
    
    app.update();
    
    let is_active = app.world.resource::<VoiceActivityState>().is_active;
    assert!(!is_active, "Should not detect voice in silent audio");
    
    // Test speaking audio
    app.world.insert_resource(MockAudioInput {
        samples: speaking_audio.clone(),
    });
    
    app.update();
    
    let is_active = app.world.resource::<VoiceActivityState>().is_active;
    assert!(is_active, "Should detect voice in speaking audio");
}
}

Integration Tests

#![allow(unused)]
fn main() {
#[test]
fn test_voice_chat_network_integration() {
    // Create test app with networking mockup
    let mut app = App::new();
    app.add_plugins(GameUiTestPlugins)
       .add_plugins(MockNetworkPlugin)
       .add_systems(Update, (
           voice_systems::capture_microphone_input,
           voice_systems::transmit_voice_packets,
           voice_systems::receive_voice_packets,
           voice_systems::play_voice_audio,
       ));
    
    // Setup test game with multiple players
    let (local_id, remote_id) = setup_test_players(&mut app);
    
    // Setup mock audio input with test samples
    let test_audio = generate_test_audio_samples();
    app.insert_resource(MockAudioInput { samples: test_audio });
    
    // Setup voice activation
    app.insert_resource(VoiceChatConfig {
        enabled: true,
        activation_mode: VoiceActivationMode::AlwaysOn,
        // ...
    });
    
    // Run capture and transmission
    app.update();
    
    // Verify packets were sent
    let sent_packets = app.world.resource::<MockNetwork>().sent_packets.clone();
    assert!(!sent_packets.is_empty(), "Voice packets should be sent");
    
    // Simulate receiving packets from the remote player
    let mut mock_network = app.world.resource_mut::<MockNetwork>();
    mock_network.simulate_receive(VoicePacketEvent {
        player_id: remote_id,
        audio_data: mock_network.sent_packets[0].audio_data.clone(),
        sequence_number: 1,
        timestamp: 0.0,
        channel: VoiceChannel::Global,
    });
    
    // Run receive and playback
    app.update();
    
    // Verify audio was queued for playback
    let output_device = app.world.resource::<MockAudioOutput>();
    assert!(!output_device.played_samples.is_empty(), "Voice audio should be played");
    
    // Verify UI indicators were updated
    let indicators = app.world.query::<&PlayerVoiceIndicator>()
        .iter(&app.world)
        .find(|i| i.player_id == remote_id && i.is_speaking);
    
    assert!(indicators.is_some(), "Remote player should be marked as speaking");
}
}

Performance Tests

#![allow(unused)]
fn main() {
#[test]
fn test_voice_chat_performance() {
    // Create test app
    let mut app = App::new();
    app.add_plugins(GameUiTestPlugins)
       .add_plugins(DiagnosticsPlugin);
    
    // Setup voice chat with max players
    setup_voice_chat_with_players(&mut app, 6);
    
    // Add performance measurement systems
    app.add_systems(Update, measure_performance);
    
    // Generate test audio for all players
    let test_audio = generate_multi_player_audio();
    app.insert_resource(MockMultiPlayerAudio { samples: test_audio });
    
    // Run system for multiple frames
    let mut cpu_usage = Vec::new();
    let mut memory_usage = Vec::new();
    
    for _ in 0..100 {
        let start = std::time::Instant::now();
        app.update();
        
        let frame_time = start.elapsed();
        cpu_usage.push(frame_time);
        
        // Measure memory usage
        let mem_usage = app.world.resource::<MemoryDiagnostics>().current_usage;
        memory_usage.push(mem_usage);
    }
    
    // Calculate average CPU and memory usage
    let avg_cpu = cpu_usage.iter().sum::<std::time::Duration>() / cpu_usage.len() as u32;
    let avg_memory = memory_usage.iter().sum::<usize>() / memory_usage.len();
    
    // Check against performance targets
    assert!(avg_cpu.as_millis() < 5, "Voice processing should use less than 5ms per frame");
    assert!(avg_memory < 10 * 1024 * 1024, "Voice chat should use less than 10MB memory");
}
}

Network Tests

#![allow(unused)]
fn main() {
#[test]
fn test_voice_chat_bandwidth_usage() {
    // Create test app with network diagnostics
    let mut app = App::new();
    app.add_plugins(GameUiTestPlugins)
       .add_plugins(NetworkDiagnosticsPlugin);
    
    // Setup voice chat
    app.insert_resource(VoiceChatConfig {
        enabled: true,
        activation_mode: VoiceActivationMode::AlwaysOn,
        // ...
    });
    
    // Setup mock audio with constant speaking
    app.insert_resource(MockAudioInput {
        samples: generate_speaking_audio(),
    });
    
    // Run for multiple frames
    let network_usage = Vec::new();
    for _ in 0..100 {
        app.update();
        
        let diagnostics = app.world.resource::<NetworkDiagnostics>();
        network_usage.push(diagnostics.bytes_sent + diagnostics.bytes_received);
    }
    
    // Calculate average bandwidth
    let avg_bandwidth = network_usage.iter().sum::<usize>() / network_usage.len();
    
    // Voice chat should use reasonable bandwidth (30 KB/s maximum)
    assert!(avg_bandwidth < 30 * 1024, "Voice chat should use less than 30 KB/s bandwidth");
}
}

Player Avatars

This document describes the player avatar system in Rummage, which provides visual representation of players in the game interface.

Table of Contents

  1. Overview
  2. Avatar Components
  3. Visual Representation
  4. Player State Indicators
  5. Customization
  6. Implementation Details
  7. Testing

Overview

The avatar system provides a visual representation of each player in the Commander game. Avatars help players:

  • Quickly identify who controls which game elements
  • See player status information at a glance
  • Express identity through customization
  • Visualize social interactions and game effects

Avatar Components

Each player avatar consists of multiple visual components:

Core Components

  • Profile Picture: The primary visual identity of the player
  • Name Plate: Displays the player's username
  • Life Counter: Shows current life total with emphasis on changes
  • Priority Indicator: Shows when a player has priority
  • Turn Indicator: Highlights the active player
  • Commander Damage Tracker: Displays commander damage received from each opponent

Example Avatar Layout

┌────────────────────────────────────┐
│  ┌──────┐   Username               │
│  │      │   Life: 40 ▲2            │
│  │ IMG  │                          │
│  │      │   ⚡ Priority             │
│  └──────┘                          │
├────────────────────────────────────┤
│ CMD DMG: P1(0) P2(6) P3(0) P4(0)   │
└────────────────────────────────────┘

Visual Representation

Avatars appear in different locations depending on the context:

In-Game Placement

  • Local Player: Usually positioned at the bottom of the screen
  • Opponents: Positioned around the virtual table based on turn order
  • Active Player: Receives visual emphasis through highlighting or scaling

Avatar Sizing

Avatars adapt to different contexts:

  • Full Size: In player information panels
  • Medium Size: At the head of playmat areas
  • Small Size: Near cards to indicate control
  • Minimal: In chat and notification areas

Player State Indicators

Avatars reflect player state through visual cues:

Game State Indicators

  • Turn Status: Highlight for active player
  • Priority Status: Indicator when player has priority
  • Thinking Status: Animation when player is taking an action
  • Passed Status: Indicator when player has passed priority

Player Status Indicators

  • Life Total: Shows current life with animations for changes
  • Hand Size: Indicates number of cards in hand
  • Disconnected Status: Indicator for disconnected players
  • Away Status: Indicator for temporarily inactive players

Hand Status Indicators

  • Card Count: Shows number of cards in hand
  • Mulligan Status: Indicates if player is mulliganing
  • Drawing Status: Animation when drawing cards

Customization

Players can customize their avatars to express identity:

Visual Customization

  • Profile Pictures: Select from built-in options or upload custom images
  • Borders: Decorative frames around profile pictures
  • Effects: Special visual effects for achievements or rankings
  • Color Schemes: Customize colors of avatar UI elements

Indicator Customization

  • Life Counter Style: Different visualization styles
  • Animation Types: Preference for animation effects
  • Sound Effects: Custom sounds for avatar-related events

Implementation Details

Avatars are implemented using Bevy's ECS system:

#![allow(unused)]
fn main() {
/// Component for player avatars
#[derive(Component)]
struct PlayerAvatar {
    player_id: PlayerId,
    display_name: String,
    profile_image: Handle<Image>,
    border_style: AvatarBorderStyle,
    customization_settings: AvatarCustomization,
}

/// Component for avatar life counter display
#[derive(Component)]
struct AvatarLifeCounter {
    player_id: PlayerId,
    current_life: i32,
    previous_life: i32,
    animation_timer: Timer,
    is_animating: bool,
}

/// Setup function for player avatars
fn setup_player_avatar(
    mut commands: Commands,
    player: &Player,
    asset_server: &Res<AssetServer>,
    position: Vec2,
) -> Entity {
    // Default profile image
    let profile_image = player.profile_image.clone()
        .unwrap_or_else(|| asset_server.load("textures/avatars/default.png"));
    
    commands
        .spawn((
            PlayerAvatar {
                player_id: player.id,
                display_name: player.name.clone(),
                profile_image: profile_image.clone(),
                border_style: AvatarBorderStyle::default(),
                customization_settings: player.avatar_customization.clone(),
            },
            Node {
                width: Val::Px(280.0),
                height: Val::Px(80.0),
                flex_direction: FlexDirection::Row,
                padding: UiRect::all(Val::Px(5.0)),
                ..default()
            },
            BackgroundColor(Color::rgba(0.1, 0.1, 0.1, 0.8)),
            BorderColor(Color::rgb(0.6, 0.6, 0.6)),
            Outline::new(Val::Px(1.0)),
            Transform::from_translation(Vec3::new(position.x, position.y, 0.0)),
            GlobalTransform::default(),
            AppLayer::GameUI.layer(),
        ))
        .with_children(|parent| {
            // Profile image container
            parent
                .spawn(Node {
                    width: Val::Px(70.0),
                    height: Val::Px(70.0),
                    margin: UiRect::right(Val::Px(10.0)),
                    ..default()
                })
                .with_children(|image_container| {
                    // Profile image
                    image_container.spawn((
                        Sprite {
                            custom_size: Some(Vec2::new(70.0, 70.0)),
                            ..default()
                        },
                        profile_image,
                    ));
                });
            
            // Information container
            parent
                .spawn(Node {
                    width: Val::Percent(100.0),
                    height: Val::Percent(100.0),
                    flex_direction: FlexDirection::Column,
                    justify_content: JustifyContent::SpaceBetween,
                    ..default()
                })
                .with_children(|info| {
                    // Username
                    info.spawn((
                        Text2d {
                            text: player.name.clone(),
                            font_size: 18.0,
                            color: Color::WHITE,
                        },
                    ));
                    
                    // Life counter
                    info.spawn((
                        Text2d {
                            text: format!("Life: {}", player.life),
                            font_size: 16.0,
                            color: Color::rgb(0.8, 0.8, 0.8),
                        },
                        AvatarLifeCounter {
                            player_id: player.id,
                            current_life: player.life,
                            previous_life: player.life,
                            animation_timer: Timer::from_seconds(0.5, TimerMode::Once),
                            is_animating: false,
                        },
                    ));
                    
                    // Priority indicator (hidden by default)
                    info.spawn((
                        Text2d {
                            text: "⚡ Priority".to_string(),
                            font_size: 16.0,
                            color: Color::YELLOW,
                        },
                        PriorityIndicator { 
                            player_id: player.id, 
                            visible: false 
                        },
                        Visibility::Hidden,
                    ));
                });
        })
        .id()
}
}

Life Total Update System

#![allow(unused)]
fn main() {
fn update_avatar_life_counters(
    time: Res<Time>,
    mut life_counter_query: Query<(&mut AvatarLifeCounter, &mut Text2d)>,
    player_query: Query<(&Player, &PlayerId)>,
) {
    for (player, player_id) in player_query.iter() {
        for (mut life_counter, mut text) in life_counter_query.iter_mut() {
            if life_counter.player_id == *player_id {
                // Check if life has changed
                if player.life != life_counter.current_life {
                    // Update previous life for animation
                    life_counter.previous_life = life_counter.current_life;
                    life_counter.current_life = player.life;
                    life_counter.is_animating = true;
                    life_counter.animation_timer.reset();
                }
                
                // Update text display
                if life_counter.is_animating {
                    life_counter.animation_timer.tick(time.delta());
                    
                    // Determine color based on life change
                    let life_change = life_counter.current_life - life_counter.previous_life;
                    let color = if life_change > 0 {
                        Color::GREEN
                    } else if life_change < 0 {
                        Color::RED
                    } else {
                        Color::WHITE
                    };
                    
                    // Format text with change indicator
                    let change_text = if life_change > 0 {
                        format!("▲{}", life_change)
                    } else if life_change < 0 {
                        format!("▼{}", life_change.abs())
                    } else {
                        String::new()
                    };
                    
                    text.text = format!("Life: {} {}", life_counter.current_life, change_text);
                    text.color = color;
                    
                    // End animation after timer completes
                    if life_counter.animation_timer.finished() {
                        life_counter.is_animating = false;
                        text.color = Color::WHITE;
                        text.text = format!("Life: {}", life_counter.current_life);
                    }
                }
            }
        }
    }
}
}

Priority Indicator System

#![allow(unused)]
fn main() {
fn update_priority_indicators(
    mut priority_query: Query<(&mut Visibility, &PriorityIndicator)>,
    game_state: Res<GameState>,
) {
    // Find player with priority
    let priority_player_id = game_state.priority_player;
    
    // Update all priority indicators
    for (mut visibility, indicator) in priority_query.iter_mut() {
        if indicator.player_id == priority_player_id {
            *visibility = Visibility::Visible;
        } else {
            *visibility = Visibility::Hidden;
        }
    }
}
}

Testing

The avatar system should be thoroughly tested to ensure proper functionality and visual appearance.

Unit Tests

  • Test initialization with different player data
  • Verify life counter updates correctly
  • Ensure priority indicators toggle correctly
  • Test avatar positioning logic

Visual Tests

  • Verify appearance across different screen sizes
  • Test with varied life totals
  • Verify animations for life changes
  • Ensure visibility of all avatar elements

Integration Tests

  • Test avatar updates with game state changes
  • Verify commander damage display updates correctly
  • Test avatar interactions with turn system

Avatar Selection

Custom Avatars

Accessibility

Screen Reader Support

This document describes how Rummage supports screen readers and implements accessible UI components.

Overview

Accessibility is a core principle of Rummage's UI design. Screen reader support ensures that players with visual impairments can fully experience the game. Implementing proper screen reader support also benefits all players by providing clearer communication of game state and actions.

Architecture

Rummage's screen reader support is built on these key components:

  1. Accessible Node Components: Bevy components that provide semantic information
  2. Screen Reader Bridge: System for communicating with platform screen reader APIs
  3. Focus Management: System for tracking and managing UI focus
  4. Keyboard Navigation: Support for navigating UI without a mouse

Accessible Components

Each UI component implements the AccessibleNode component:

#![allow(unused)]
fn main() {
#[derive(Component)]
pub struct AccessibleNode {
    /// Human-readable label for the element
    pub label: String,
    /// Semantic role of the element
    pub role: AccessibilityRole,
    /// Current state of the element
    pub state: AccessibilityState,
    /// Additional properties
    pub properties: HashMap<String, String>,
}

#[derive(PartialEq, Eq, Clone, Copy, Debug)]
pub enum AccessibilityRole {
    Button,
    Card,
    Checkbox,
    Dialog,
    Grid,
    GridCell,
    Image,
    Link,
    List,
    ListItem,
    Menu,
    MenuItem,
    Slider,
    Tab,
    TabPanel,
    Text,
    // Game-specific roles
    GameZone,
    PlayerInfo,
    PhaseIndicator,
}

#[derive(Default)]
pub struct AccessibilityState {
    pub disabled: bool,
    pub selected: bool,
    pub focused: bool,
    pub expanded: bool,
    pub pressed: bool,
}
}

Implementation Examples

Card Component

Here's how a card component implements screen reader accessibility:

#![allow(unused)]
fn main() {
fn spawn_card_entity(
    commands: &mut Commands,
    card_data: &CardData,
) -> Entity {
    commands.spawn((
        // Visual components
        SpriteBundle { ... },
        
        // Game logic components
        Card { ... },
        
        // Accessibility component
        AccessibleNode {
            label: format!("{}, {}", card_data.name, card_data.type_line),
            role: AccessibilityRole::Card,
            state: AccessibilityState {
                selected: false,
                ..default()
            },
            properties: {
                let mut props = HashMap::new();
                props.insert("power".to_string(), card_data.power.to_string());
                props.insert("toughness".to_string(), card_data.toughness.to_string());
                props.insert("rules_text".to_string(), card_data.rules_text.clone());
                props
            },
        },
    )).id()
}
}

Game Zone

Game zones are important landmarks for screen reader navigation:

#![allow(unused)]
fn main() {
fn spawn_hand_zone(commands: &mut Commands) -> Entity {
    commands.spawn((
        // Visual components
        NodeBundle { ... },
        
        // Game zone component
        HandZone { ... },
        
        // Accessibility component
        AccessibleNode {
            label: "Hand".to_string(),
            role: AccessibilityRole::GameZone,
            state: default(),
            properties: {
                let mut props = HashMap::new();
                props.insert("card_count".to_string(), "0".to_string());
                props
            },
        },
    )).id()
}
}

Focus Management

The focus management system tracks which element has keyboard focus:

#![allow(unused)]
fn main() {
fn update_focus(
    keyboard_input: Res<Input<KeyCode>>,
    mut focused_entity: ResMut<FocusedEntity>,
    mut query: Query<(Entity, &mut AccessibleNode)>,
) {
    // Handle Tab navigation
    if keyboard_input.just_pressed(KeyCode::Tab) {
        let shift = keyboard_input.pressed(KeyCode::ShiftLeft) || 
                    keyboard_input.pressed(KeyCode::ShiftRight);
        
        if shift {
            focused_entity.focus_previous(&mut query);
        } else {
            focused_entity.focus_next(&mut query);
        }
    }
    
    // Update accessibility state based on focus
    for (entity, mut node) in query.iter_mut() {
        node.state.focused = Some(entity) == focused_entity.0;
    }
}
}

Screen Reader Announcements

The game communicates important events to screen readers:

#![allow(unused)]
fn main() {
fn announce_phase_change(
    mut phase_events: EventReader<PhaseChangeEvent>,
    mut screen_reader: ResMut<ScreenReaderBridge>,
) {
    for event in phase_events.read() {
        let announcement = format!(
            "Phase changed to {} for player {}",
            event.new_phase.name(),
            event.active_player.name
        );
        
        screen_reader.announce(announcement);
    }
}
}

Card State Announcements

Changes to card state are announced to the screen reader:

#![allow(unused)]
fn main() {
fn announce_card_state_changes(
    mut card_events: EventReader<CardStateChangeEvent>,
    mut screen_reader: ResMut<ScreenReaderBridge>,
    card_query: Query<&Card>,
) {
    for event in card_events.read() {
        if let Ok(card) = card_query.get(event.card_entity) {
            let announcement = match event.state_change {
                CardStateChange::Tapped => format!("{} tapped", card.name),
                CardStateChange::Untapped => format!("{} untapped", card.name),
                CardStateChange::Destroyed => format!("{} destroyed", card.name),
                CardStateChange::Exiled => format!("{} exiled", card.name),
                // Other state changes
            };
            
            screen_reader.announce(announcement);
        }
    }
}
}

Keyboard Shortcuts

Rummage provides comprehensive keyboard shortcuts for gameplay:

KeyAction
SpaceSelect/Activate focused element
TabMove focus to next element
Shift+TabMove focus to previous element
Arrow keysNavigate within a zone or component
1-9Select cards in hand
PPass priority
MOpen mana pool
CView card details
EscCancel current action

Testing Screen Reader Support

Screen reader support is tested through:

  1. Unit tests: Test the accessibility components and focus management
  2. Integration tests: Test the screen reader bridge with mock screen readers
  3. End-to-end tests: Test with actual screen readers on target platforms
  4. User testing: Work with visually impaired players to validate usability

Platform Support

Rummage supports these screen reader platforms:

  • Windows: NVDA and JAWS
  • macOS: VoiceOver
  • Linux: Orca
  • Web: ARIA support for browser-based screen readers

Future Improvements

Planned improvements for screen reader support:

  • Enhanced contextual descriptions for complex board states
  • Custom screen reader modes for different game phases
  • Integrated tutorials specific to screen reader users
  • Support for braille displays

Best Practices

When implementing new UI components, follow these accessibility best practices:

  1. Always include an AccessibleNode component with appropriate role and label
  2. Ensure all interactive elements are keyboard navigable
  3. Announce important state changes
  4. Test with actual screen readers
  5. Group related elements semantically

Color Contrast

Control Options

Game UI Testing Guide

This document outlines testing strategies and methodologies for Rummage's game UI system, with a focus on ensuring stability, correctness, and usability across different scenarios.

Table of Contents

  1. Testing Philosophy
  2. Unit Testing
  3. Integration Testing
  4. Visual Regression Testing
  5. Performance Testing
  6. Accessibility Testing
  7. Automation Framework
  8. Test Case Organization

Testing Philosophy

The UI testing approach for Rummage follows these core principles:

  1. Comprehensive Coverage: Test all UI components across different configurations
  2. Behavior-Driven: Focus on testing functionality from a user perspective
  3. Automated Where Possible: Leverage automation for regression testing
  4. Visual Correctness: Ensure visual elements appear as designed
  5. Performance Aware: Verify UI performs well under different conditions

Unit Testing

Unit tests focus on testing individual UI components in isolation.

Component Testing

Test each UI component separately to verify:

  • Correct initialization
  • Proper event handling
  • State transitions
  • Component lifecycle behaviors

Example test for a component:

#![allow(unused)]
fn main() {
#[test]
fn test_hand_zone_initialization() {
    // Create app with test plugins
    let mut app = App::new();
    app.add_plugins(MinimalPlugins)
       .add_systems(Update, ui_systems::update_hand_zone);
    
    // Setup test state
    let player_id = PlayerId(1);
    let test_cards = vec![
        Card::new("Test Card 1"),
        Card::new("Test Card 2"),
        Card::new("Test Card 3"),
    ];
    
    // Spawn hand zone with test cards
    app.world.spawn((
        HandZone { player_id, expanded: false },
        Node {
            width: Val::Percent(100.0),
            height: Val::Px(200.0),
            ..default()
        },
    ));
    
    // Add cards to player's hand
    let hand = Hand { cards: test_cards, player_id };
    app.world.insert_resource(hand);
    
    // Run systems
    app.update();
    
    // Verify hand zone contains correct number of card entities
    let hand_entity = app.world.query_filtered::<Entity, With<HandZone>>().single(&app.world);
    let children = app.world.get::<Children>(hand_entity).unwrap();
    assert_eq!(children.len(), 3, "Hand zone should contain 3 card entities");
}
}

Event Handling Tests

Test that UI components respond correctly to events:

#![allow(unused)]
fn main() {
#[test]
fn test_card_drag_event_handling() {
    // Create test app
    let mut app = App::new();
    app.add_plugins(MinimalPlugins)
       .add_event::<CardDragStartEvent>()
       .add_event::<CardDragEndEvent>()
       .add_systems(Update, ui_systems::handle_card_drag_events);
    
    // Set up test entities
    let card_entity = app.world.spawn(Card::new("Test Card")).id();
    
    // Trigger drag start event
    app.world.send_event(CardDragStartEvent {
        card_entity,
        cursor_position: Vec2::new(100.0, 100.0),
    });
    
    // Run systems
    app.update();
    
    // Verify card is being dragged
    let dragging = app.world.get::<Dragging>(card_entity).unwrap();
    assert!(dragging.active, "Card should be in dragging state");
    
    // Trigger drag end event
    app.world.send_event(CardDragEndEvent {
        card_entity,
        cursor_position: Vec2::new(200.0, 200.0),
    });
    
    // Run systems
    app.update();
    
    // Verify card is no longer being dragged
    assert!(app.world.get::<Dragging>(card_entity).is_none(), "Dragging component should be removed");
}
}

Layout Tests

Verify that UI layout components arrange children correctly:

#![allow(unused)]
fn main() {
#[test]
fn test_battlefield_layout() {
    // Create test app
    let mut app = App::new();
    app.add_plugins(MinimalPlugins)
       .add_systems(Update, ui_systems::position_battlefield_cards);
    
    // Set up battlefield entity with cards
    let battlefield_entity = app.world.spawn((
        BattlefieldZone {
            player_id: PlayerId(1),
            organization: BattlefieldOrganization::ByType,
        },
        Node {
            width: Val::Percent(100.0),
            height: Val::Percent(100.0),
            ..default()
        },
        Transform::default(),
        GlobalTransform::default(),
    )).id();
    
    // Add some test cards to the battlefield
    let card_entities = (0..5).map(|i| {
        let card_entity = app.world.spawn((
            Card::new(&format!("Test Card {}", i)),
            CardType::Creature,
            Transform::default(),
            GlobalTransform::default(),
            Parent(battlefield_entity),
        )).id();
        app.world.entity_mut(battlefield_entity).add_child(card_entity);
        card_entity
    }).collect::<Vec<_>>();
    
    // Run systems
    app.update();
    
    // Verify cards are positioned correctly
    for (i, card_entity) in card_entities.iter().enumerate() {
        let transform = app.world.get::<Transform>(*card_entity).unwrap();
        // Cards should be arranged in a row with spacing
        assert_approx_eq!(transform.translation.x, i as f32 * 120.0, 1.0);
    }
}
}

Integration Testing

Integration tests verify that multiple UI components work together correctly.

Playmat Integration Tests

Test that all zones in a player's playmat interact correctly:

#![allow(unused)]
fn main() {
#[test]
fn test_card_movement_between_zones() {
    // Create test app
    let mut app = App::new();
    app.add_plugins(GameUiTestPlugins)
       .add_systems(Update, (
           ui_systems::handle_card_movement,
           ui_systems::update_zones,
       ));
    
    // Set up test player and playmat
    let player_id = PlayerId(1);
    setup_test_playmat(&mut app, player_id);
    
    // Get zone entities
    let hand_entity = app.world.query_filtered::<Entity, With<HandZone>>().single(&app.world);
    let battlefield_entity = app.world.query_filtered::<Entity, With<BattlefieldZone>>().single(&app.world);
    
    // Create test card in hand
    let card_entity = app.world.spawn((
        Card::new("Test Creature"),
        CardType::Creature,
        Transform::default(),
        GlobalTransform::default(),
        Parent(hand_entity),
        InZone::Hand,
    )).id();
    app.world.entity_mut(hand_entity).add_child(card_entity);
    
    // Simulate playing card from hand to battlefield
    app.world.send_event(PlayCardEvent {
        card_entity,
        source_zone: Zone::Hand,
        destination_zone: Zone::Battlefield,
        player_id,
    });
    
    // Run systems
    app.update();
    
    // Verify card moved to battlefield
    let card_parent = app.world.get::<Parent>(card_entity).unwrap();
    assert_eq!(card_parent.get(), battlefield_entity, "Card should be in battlefield");
    
    let in_zone = app.world.get::<InZone>(card_entity).unwrap();
    assert_eq!(*in_zone, InZone::Battlefield, "Card zone should be Battlefield");
}
}

Table Integration Tests

Test the entire table layout with multiple players:

#![allow(unused)]
fn main() {
#[test]
fn test_four_player_table_layout() {
    // Create test app
    let mut app = App::new();
    app.add_plugins(GameUiTestPlugins)
       .add_systems(Update, ui_systems::update_table_layout);
    
    // Set up game state with 4 players
    let mut game_state = GameState::default();
    game_state.player_count = 4;
    app.world.insert_resource(game_state);
    
    // Set up virtual table
    let table_entity = app.world.spawn((
        VirtualTable,
        Node {
            width: Val::Percent(100.0),
            height: Val::Percent(100.0),
            ..default()
        },
    )).id();
    
    // Trigger table setup
    app.world.send_event(SetupTableEvent { player_count: 4 });
    
    // Run systems
    app.update();
    
    // Verify table has correct structure for 4 players
    let children = app.world.get::<Children>(table_entity).unwrap();
    
    // Should have 5 children: 4 player playmats + shared area
    assert_eq!(children.len(), 5, "Table should have 5 main areas for 4 players");
    
    // Verify each player's playmat exists and has correct position
    let playmat_query = app.world.query_filtered::<(&PlayerPlaymat, &Node), With<Node>>();
    assert_eq!(playmat_query.iter(&app.world).count(), 4, "Should have 4 player playmats");
}
}

UI Flow Tests

Test complete UI workflows from start to finish:

#![allow(unused)]
fn main() {
#[test]
fn test_cast_spell_targeting_ui_flow() {
    // Create test app
    let mut app = App::new();
    app.add_plugins(GameUiTestPlugins)
       .add_systems(Update, (
           ui_systems::handle_card_selection,
           ui_systems::handle_targeting,
           ui_systems::update_stack_visualization,
       ));
    
    // Set up game with 2 players
    setup_test_game(&mut app, 2);
    
    // Get player hand and opponent battlefield
    let player_id = PlayerId(1);
    let opponent_id = PlayerId(2);
    let hand_entity = get_player_zone_entity(&app, player_id, ZoneType::Hand);
    let opp_battlefield_entity = get_player_zone_entity(&app, opponent_id, ZoneType::Battlefield);
    
    // Add test spell to player's hand
    let spell_entity = app.world.spawn((
        Card::new("Test Lightning Bolt"),
        CardType::Instant,
        RequiresTarget { valid_targets: TargetType::CreatureOrPlayer },
        Transform::default(),
        GlobalTransform::default(),
        Parent(hand_entity),
        InZone::Hand,
    )).id();
    app.world.entity_mut(hand_entity).add_child(spell_entity);
    
    // Add creature to opponent's battlefield
    let creature_entity = app.world.spawn((
        Card::new("Test Creature"),
        CardType::Creature,
        Transform::default(),
        GlobalTransform::default(),
        Parent(opp_battlefield_entity),
        InZone::Battlefield,
    )).id();
    app.world.entity_mut(opp_battlefield_entity).add_child(creature_entity);
    
    // Simulate spell cast
    app.world.send_event(CardSelectedEvent {
        card_entity: spell_entity,
        player_id,
    });
    app.update();
    
    // Verify targeting mode is active
    let ui_state = app.world.resource::<UiState>();
    assert_eq!(ui_state.mode, UiMode::Targeting, "UI should be in targeting mode");
    
    // Simulate target selection
    app.world.send_event(TargetSelectedEvent {
        source_entity: spell_entity,
        target_entity: creature_entity,
        player_id,
    });
    app.update();
    
    // Verify spell is on stack
    let stack_entity = app.world.query_filtered::<Entity, With<StackZone>>().single(&app.world);
    let stack_children = app.world.get::<Children>(stack_entity).unwrap();
    assert!(!stack_children.is_empty(), "Stack should contain the cast spell");
    
    // Verify targeting visualization
    let targeting = app.world.query_filtered::<&TargetingVisualization, With<TargetingVisualization>>().single(&app.world);
    assert_eq!(targeting.source, spell_entity);
    assert_eq!(targeting.target, creature_entity);
}
}

Visual Regression Testing

Visual tests verify that UI components appear correctly.

Screenshot Comparison Tests

Compare screenshots of UI elements against reference images:

#![allow(unused)]
fn main() {
#[test]
fn test_card_appearance() {
    // Create test app
    let mut app = App::new();
    app.add_plugins(VisualTestPlugins)
       .add_systems(Update, ui_systems::render_card);
    
    // Set up test card
    let card = Card::new("Test Card");
    card.card_type = CardType::Creature;
    card.mana_cost = "2R".into();
    
    app.world.spawn((
        card,
        Transform::from_xyz(400.0, 300.0, 0.0),
        GlobalTransform::default(),
    ));
    
    // Render frame
    app.update();
    
    // Take screenshot
    let screenshot = take_screenshot(&app);
    
    // Compare with reference image
    let reference = load_reference_image("card_appearance.png");
    let difference = compare_images(&screenshot, &reference);
    
    assert!(difference < 0.01, "Card appearance doesn't match reference");
}
}

Layout Verification Tests

Verify layout properties of UI elements:

#![allow(unused)]
fn main() {
#[test]
fn test_playmat_layout_proportions() {
    // Create test app
    let mut app = App::new();
    app.add_plugins(GameUiTestPlugins);
    
    // Set up test playmat
    setup_test_playmat(&mut app, PlayerId(1));
    
    // Run systems
    app.update();
    
    // Query zone entities
    let hand_query = app.world.query_filtered::<&Node, With<HandZone>>();
    let battlefield_query = app.world.query_filtered::<&Node, With<BattlefieldZone>>();
    
    // Verify hand zone height
    let hand_node = hand_query.single(&app.world);
    match hand_node.height {
        Val::Percent(percent) => {
            assert!(percent > 15.0 && percent < 25.0, "Hand zone height should be ~20%");
        },
        _ => panic!("Hand height should be a percentage"),
    }
    
    // Verify battlefield zone height
    let battlefield_node = battlefield_query.single(&app.world);
    match battlefield_node.height {
        Val::Percent(percent) => {
            assert!(percent > 50.0 && percent < 70.0, "Battlefield height should be ~60%");
        },
        _ => panic!("Battlefield height should be a percentage"),
    }
}
}

Performance Testing

Performance tests verify that the UI performs well under different conditions.

Card Volume Tests

Test UI performance with large numbers of cards:

#![allow(unused)]
fn main() {
#[test]
fn test_battlefield_performance_with_many_cards() {
    // Create test app
    let mut app = App::new();
    app.add_plugins(GameUiTestPlugins)
       .add_plugins(DiagnosticsPlugin);
    
    // Set up battlefield with many cards
    let battlefield_entity = app.world.spawn((
        BattlefieldZone {
            player_id: PlayerId(1),
            organization: BattlefieldOrganization::ByType,
        },
        Node {
            width: Val::Percent(100.0),
            height: Val::Percent(100.0),
            ..default()
        },
    )).id();
    
    // Add 100 test cards to the battlefield
    for i in 0..100 {
        let card_entity = app.world.spawn((
            Card::new(&format!("Test Card {}", i)),
            CardType::Creature,
            Transform::default(),
            GlobalTransform::default(),
        )).id();
        app.world.entity_mut(battlefield_entity).add_child(card_entity);
    }
    
    // Run performance test
    let mut frame_times = Vec::new();
    for _ in 0..100 {
        let start = std::time::Instant::now();
        app.update();
        frame_times.push(start.elapsed());
    }
    
    // Calculate average frame time
    let avg_frame_time = frame_times.iter().sum::<std::time::Duration>() / frame_times.len() as u32;
    
    // Average frame time should be under 16ms (60fps)
    assert!(avg_frame_time.as_millis() < 16, "UI is not performing well with many cards");
}
}

Animation Performance Tests

Test performance of UI animations:

#![allow(unused)]
fn main() {
#[test]
fn test_card_draw_animation_performance() {
    // Create test app
    let mut app = App::new();
    app.add_plugins(GameUiTestPlugins)
       .add_plugins(DiagnosticsPlugin);
    
    // Set up player zones
    setup_test_playmat(&mut app, PlayerId(1));
    
    // Get zone entities
    let library_entity = app.world.query_filtered::<Entity, With<LibraryZone>>().single(&app.world);
    let hand_entity = app.world.query_filtered::<Entity, With<HandZone>>().single(&app.world);
    
    // Set up library with cards
    for i in 0..50 {
        let card_entity = app.world.spawn((
            Card::new(&format!("Library Card {}", i)),
            Transform::default(),
            GlobalTransform::default(),
            Parent(library_entity),
            InZone::Library,
        )).id();
        app.world.entity_mut(library_entity).add_child(card_entity);
    }
    
    // Measure performance while drawing multiple cards
    let mut frame_times = Vec::new();
    for i in 0..7 {
        // Draw a card
        let card_entity = app.world.query_filtered::<Entity, (With<Card>, With<InZone>)>()
            .iter(&app.world)
            .next()
            .unwrap();
            
        app.world.send_event(DrawCardEvent {
            player_id: PlayerId(1),
            card_entity,
        });
        
        // Run update and measure frame time
        let start = std::time::Instant::now();
        app.update();
        frame_times.push(start.elapsed());
    }
    
    // Calculate average and maximum frame time
    let avg_frame_time = frame_times.iter().sum::<std::time::Duration>() / frame_times.len() as u32;
    let max_frame_time = frame_times.iter().max().unwrap();
    
    // Performance assertions
    assert!(avg_frame_time.as_millis() < 16, "Card draw animation average performance is too slow");
    assert!(max_frame_time.as_millis() < 32, "Card draw animation maximum frame time is too slow");
}
}

Accessibility Testing

Tests to verify UI accessibility features.

Color Blind Mode Tests

Test that UI is usable in color blind modes:

#![allow(unused)]
fn main() {
#[test]
fn test_color_blind_mode_card_distinction() {
    // Create test app
    let mut app = App::new();
    app.add_plugins(GameUiTestPlugins);
    
    // Set accessibility settings to deuteranopia mode
    let mut settings = AccessibilitySettings::default();
    settings.color_blind_mode = ColorBlindMode::Deuteranopia;
    app.world.insert_resource(settings);
    
    // Set up cards of different colors
    let red_card = app.world.spawn((
        Card::new("Red Card"),
        CardColor::Red,
        Transform::from_xyz(100.0, 100.0, 0.0),
        GlobalTransform::default(),
    )).id();
    
    let green_card = app.world.spawn((
        Card::new("Green Card"),
        CardColor::Green,
        Transform::from_xyz(300.0, 100.0, 0.0),
        GlobalTransform::default(),
    )).id();
    
    // Run systems to update card appearance
    app.update();
    
    // Get card visual components
    let red_card_appearance = app.world.get::<CardAppearance>(red_card).unwrap();
    let green_card_appearance = app.world.get::<CardAppearance>(green_card).unwrap();
    
    // In deuteranopia mode, these colors should have distinct patterns or indicators
    // rather than relying solely on red/green color difference
    assert_ne!(
        red_card_appearance.pattern_type,
        green_card_appearance.pattern_type,
        "Cards should have distinct patterns in color blind mode"
    );
}
}

Keyboard Navigation Tests

Test keyboard navigation through UI elements:

#![allow(unused)]
fn main() {
#[test]
fn test_keyboard_navigation() {
    // Create test app
    let mut app = App::new();
    app.add_plugins(GameUiTestPlugins)
       .add_systems(Update, ui_systems::handle_keyboard_input);
    
    // Set up test game
    setup_test_game(&mut app, 2);
    
    // Initialize UI focus state
    app.world.insert_resource(UiFocus {
        current_focus: None,
        navigation_mode: true,
    });
    
    // Simulate keyboard input to start navigation
    app.world.send_event(KeyboardInput {
        key: KeyCode::Tab,
        state: ButtonState::Pressed,
    });
    app.update();
    
    // Verify navigation mode is active and something is focused
    let ui_focus = app.world.resource::<UiFocus>();
    assert!(ui_focus.navigation_mode, "Keyboard navigation mode should be active");
    assert!(ui_focus.current_focus.is_some(), "An element should be focused");
    
    // Test navigation between elements
    let first_focus = ui_focus.current_focus;
    
    // Simulate arrow key press
    app.world.send_event(KeyboardInput {
        key: KeyCode::ArrowRight,
        state: ButtonState::Pressed,
    });
    app.update();
    
    // Verify focus changed
    let ui_focus = app.world.resource::<UiFocus>();
    assert_ne!(ui_focus.current_focus, first_focus, "Focus should have moved to a new element");
}
}

Automation Framework

The testing framework uses several helper functions and utilities:

#![allow(unused)]
fn main() {
/// Set up a test playmat with all zones for a player
fn setup_test_playmat(app: &mut App, player_id: PlayerId) -> Entity {
    // Create player entity
    let player_entity = app.world.spawn((
        Player { id: player_id, name: format!("Test Player {}", player_id.0) },
    )).id();
    
    // Spawn playmat
    let playmat_entity = app.world.spawn((
        PlayerPlaymat { player_id },
        Node {
            width: Val::Percent(100.0),
            height: Val::Percent(100.0),
            flex_direction: FlexDirection::Column,
            ..default()
        },
    )).id();
    
    // Spawn hand zone
    let hand_entity = app.world.spawn((
        HandZone { player_id, expanded: false },
        Node {
            width: Val::Percent(100.0),
            height: Val::Percent(20.0),
            ..default()
        },
        Parent(playmat_entity),
    )).id();
    app.world.entity_mut(playmat_entity).add_child(hand_entity);
    
    // Spawn battlefield zone
    let battlefield_entity = app.world.spawn((
        BattlefieldZone {
            player_id,
            organization: BattlefieldOrganization::ByType,
        },
        Node {
            width: Val::Percent(100.0),
            height: Val::Percent(60.0),
            ..default()
        },
        Parent(playmat_entity),
    )).id();
    app.world.entity_mut(playmat_entity).add_child(battlefield_entity);
    
    // Add other zones (library, graveyard, etc.)
    // ...
    
    playmat_entity
}

/// Set up a test game with the specified number of players
fn setup_test_game(app: &mut App, player_count: usize) {
    // Create game state
    let mut game_state = GameState::default();
    game_state.player_count = player_count;
    app.world.insert_resource(game_state);
    
    // Create table
    let table_entity = app.world.spawn((
        VirtualTable,
        Node {
            width: Val::Percent(100.0),
            height: Val::Percent(100.0),
            ..default()
        },
    )).id();
    
    // Add playmats for each player
    for i in 0..player_count {
        let player_id = PlayerId(i as u32 + 1);
        let playmat_entity = setup_test_playmat(app, player_id);
        app.world.entity_mut(table_entity).add_child(playmat_entity);
    }
    
    // Add shared zones
    setup_shared_zones(app, table_entity);
}

/// Helper function to take screenshot in tests
fn take_screenshot(app: &App) -> Image {
    // Implementation would depend on rendering backend
    // This is a simplified example
    let mut image = Image::default();
    
    // Get main camera
    let camera_entity = app.world.query_filtered::<Entity, With<Camera>>().single(&app.world);
    
    // Render to texture
    // (Implementation details would depend on Bevy's rendering API)
    
    image
}
}

Test Case Organization

Test cases are organized into test suites that focus on specific aspects of the UI:

  1. Layout Tests: Verify correct positioning and sizing of UI elements
  2. Interaction Tests: Verify user interactions work correctly
  3. Visual Tests: Verify visual appearance matches expectations
  4. Performance Tests: Verify UI performance under different conditions
  5. Accessibility Tests: Verify accessibility features work correctly
  6. Integration Tests: Verify different UI components work together

Each test suite is implemented as a separate module, with common helpers in a shared module:

#![allow(unused)]
fn main() {
mod layout_tests {
    use super::*;
    
    #[test]
    fn test_two_player_table_layout() { /* ... */ }
    
    #[test]
    fn test_four_player_table_layout() { /* ... */ }
    
    // More layout tests...
}

mod interaction_tests {
    use super::*;
    
    #[test]
    fn test_card_selection() { /* ... */ }
    
    #[test]
    fn test_drag_and_drop() { /* ... */ }
    
    // More interaction tests...
}

// More test modules...
}

When running tests, use tags to run specific categories:

cargo test ui::layout  # Run layout tests
cargo test ui::visual  # Run visual tests
cargo test ui::all     # Run all UI tests

UI Component Testing

This guide covers how to test UI components in the Rummage game engine.

Overview

Testing UI components is essential to ensure that the game's interface remains consistent, responsive, and intuitive. In Rummage, we use a combination of unit tests, integration tests, and visual regression tests to validate our UI components.

Testing Approach

Our UI component testing strategy follows these principles:

  1. Component Isolation: Test individual UI components in isolation
  2. Behavior Verification: Verify that components respond correctly to user interactions
  3. Layout Validation: Ensure components maintain proper layout across different screen sizes
  4. Integration Testing: Test how components interact with each other
  5. Visual Consistency: Maintain visual appearance through visual regression testing

Test Structure

UI component tests are organized as follows:

src/
  tests/
    ui/
      components/
        buttons_test.rs
        cards_test.rs
        menus_test.rs
      interactions/
        drag_drop_test.rs
        selection_test.rs

Testing UI Components with Bevy

Bevy's entity-component-system architecture requires a specific approach to UI testing.

Component Unit Tests

For component unit tests, we focus on testing the component's properties and behaviors:

#![allow(unused)]
fn main() {
#[test]
fn test_button_component() {
    // Create a test app with required plugins
    let mut app = App::new();
    app.add_plugins(MinimalPlugins)
       .add_plugin(UiPlugin);
    
    // Spawn a button entity
    let button_entity = app.world.spawn((
        ButtonBundle {
            style: Style {
                width: Val::Px(150.0),
                height: Val::Px(50.0),
                ..default()
            },
            background_color: Color::rgb(0.15, 0.15, 0.15).into(),
            ..default()
        },
        Button {},
    )).id();
    
    // Test button properties
    let button_query = app.world.query::<(&Button, &Style)>();
    let (_, style) = button_query.get(&app.world, button_entity).unwrap();
    
    assert_eq!(style.width, Val::Px(150.0));
    assert_eq!(style.height, Val::Px(50.0));
}
}

Interaction Tests

Interaction tests validate how components respond to user input:

#![allow(unused)]
fn main() {
#[test]
fn test_button_click() {
    // Create a test app with required plugins
    let mut app = App::new();
    app.add_plugins(MinimalPlugins)
       .add_plugin(UiPlugin)
       .add_event::<ButtonClickEvent>();
    
    // Add a system to handle button interaction
    app.add_systems(Update, button_click_system);
    
    // Spawn a button entity
    let button_entity = app.world.spawn((
        ButtonBundle { ... },
        Button {},
    )).id();
    
    // Simulate interaction with the button
    app.world.entity_mut(button_entity)
             .insert(Interaction::Clicked);
    
    // Run systems
    app.update();
    
    // Check if appropriate events were generated
    let button_events = app.world.resource::<Events<ButtonClickEvent>>();
    let mut reader = button_events.get_reader();
    let events: Vec<_> = reader.read(button_events).collect();
    
    assert_eq!(events.len(), 1);
}
}

Layout Tests

Layout tests verify that UI components maintain proper layout:

#![allow(unused)]
fn main() {
#[test]
fn test_responsive_layout() {
    // Create a test app
    let mut app = App::new();
    app.add_plugins(MinimalPlugins)
       .add_plugin(UiPlugin);
    
    // Create a responsive UI component
    let panel_entity = app.world.spawn((
        NodeBundle {
            style: Style {
                width: Val::Percent(100.0),
                // Other style properties
                ..default()
            },
            ..default()
        },
    )).id();
    
    // Test different window sizes
    for window_width in [800.0, 1200.0, 1600.0] {
        // Update window size
        app.world.resource_mut::<Windows>().primary_mut().set_resolution(window_width, 900.0);
        
        // Run layout systems
        app.update();
        
        // Verify layout adaptation
        // ...
    }
}
}

Visual Regression Testing

For visual regression testing, see the Visual Regression guide.

Integration with Game Logic

UI component tests should also verify correct integration with game logic when applicable:

#![allow(unused)]
fn main() {
#[test]
fn test_card_drag_affects_game_state() {
    // Setup test app with UI and game state
    let mut app = App::new();
    app.add_plugins(MinimalPlugins)
       .add_plugin(UiPlugin)
       .add_plugin(GameStatePlugin);
    
    // Setup game state
    // ...
    
    // Test drag interaction
    // ...
    
    // Verify game state was updated correctly
    // ...
}
}

Testing Accessibility

UI component tests should also verify accessibility requirements:

#![allow(unused)]
fn main() {
#[test]
fn test_button_accessibility() {
    // Create a test app
    let mut app = App::new();
    app.add_plugins(MinimalPlugins)
       .add_plugin(UiPlugin)
       .add_plugin(AccessibilityPlugin);
    
    // Create a button with accessibility attributes
    let button_entity = app.world.spawn((
        ButtonBundle { ... },
        Button {},
        AccessibilityNode {
            label: "Submit".into(),
            role: AccessibilityRole::Button,
            ..default()
        },
    )).id();
    
    // Verify accessibility properties
    let accessibility_query = app.world.query::<&AccessibilityNode>();
    let accessibility = accessibility_query.get(&app.world, button_entity).unwrap();
    
    assert_eq!(accessibility.label, "Submit");
    assert_eq!(accessibility.role, AccessibilityRole::Button);
}
}

Best Practices

  1. Test Real Scenarios: Focus on testing real user scenarios rather than implementation details
  2. Isolate UI Logic: Keep UI logic separate from game logic to make testing easier
  3. Test Different Screen Sizes: Verify that UI works across different screen resolutions
  4. Test Accessibility: Ensure UI components meet accessibility standards
  5. Use Visual Regression Tests: Complement code tests with visual regression tests

Visual Regression Testing

This guide covers visual regression testing for the Rummage game's UI components, ensuring visual consistency across updates.

Introduction to Visual Regression Testing

Visual regression testing is a technique that ensures UI components maintain their visual appearance across code changes. It works by capturing screenshots of UI components and comparing them against baseline images to detect unexpected visual changes.

In Rummage, visual regression testing is essential for maintaining a consistent, polished user interface across the diverse board states that can occur in a Magic: The Gathering game.

Visual Testing Framework

Rummage uses a custom visual testing framework built on top of Bevy's testing capabilities, with these key components:

  1. Snapshot Capture: Renders UI components to off-screen buffers and captures their visual state
  2. Image Comparison: Compares captured images against baseline images pixel-by-pixel
  3. Difference Visualization: Generates difference images highlighting visual changes
  4. Test Reporting: Provides detailed reports on visual changes

Setting Up Visual Regression Tests

Visual regression tests are located in the tests/visual directory and follow this structure:

tests/
  visual/
    components/
      card_test.rs
      menu_test.rs
    screens/
      battlefield_test.rs
      hand_test.rs
    reference_images/
      card_normal.png
      card_tapped.png

Writing Visual Regression Tests

Here's how to write a visual regression test:

#![allow(unused)]
fn main() {
#[test]
fn test_card_visual_appearance() {
    // Set up a test app with required plugins
    let mut app = App::new();
    app.add_plugins(MinimalPlugins)
       .add_plugin(RenderPlugin)
       .add_plugin(UiPlugin)
       .add_plugin(VisualTestPlugin);
    
    // Create a test card entity
    let card_entity = app.world.spawn((
        SpriteBundle {
            sprite: Sprite {
                color: Color::rgb(1.0, 1.0, 1.0),
                custom_size: Some(Vec2::new(63.0, 88.0)),
                ..default()
            },
            transform: Transform::from_translation(Vec3::new(0.0, 0.0, 0.0)),
            ..default()
        },
        Card {
            name: "Test Card".to_string(),
            // Other card properties
        },
        CardVisual {
            state: CardVisualState::Normal,
        },
    )).id();
    
    // Set up camera
    app.world.spawn(Camera2dBundle::default());
    
    // Capture and compare visual state
    let visual_test = VisualTest::new(&mut app)
        .capture_entity(card_entity)
        .compare_to_reference("card_normal.png")
        .with_tolerance(0.01);  // 1% pixel difference tolerance
    
    // Assert visual consistency
    assert!(visual_test.is_visually_consistent());
}
}

Testing Different Visual States

It's important to test different visual states of UI components:

#![allow(unused)]
fn main() {
#[test]
fn test_card_visual_states() {
    // Set up test app and card entity
    // ...
    
    // Test normal state
    let visual_test = VisualTest::new(&mut app)
        .capture_entity(card_entity)
        .compare_to_reference("card_normal.png");
    assert!(visual_test.is_visually_consistent());
    
    // Change to tapped state
    app.world.entity_mut(card_entity).get_mut::<CardVisual>().unwrap().state = CardVisualState::Tapped;
    app.world.entity_mut(card_entity).get_mut::<Transform>().unwrap().rotation = Quat::from_rotation_z(std::f32::consts::FRAC_PI_2);
    
    // Update systems to apply visual changes
    app.update();
    
    // Test tapped state
    let visual_test = VisualTest::new(&mut app)
        .capture_entity(card_entity)
        .compare_to_reference("card_tapped.png");
    assert!(visual_test.is_visually_consistent());
}
}

Testing Responsive Layouts

Visual tests should also verify appearance across different screen sizes:

#![allow(unused)]
fn main() {
#[test]
fn test_responsive_menu_layout() {
    // Set up test app
    // ...
    
    // Test different screen sizes
    for (width, height, suffix) in [
        (1920, 1080, "full_hd"),
        (1280, 720, "hd"),
        (800, 600, "low_res"),
    ] {
        // Set window size
        app.world.resource_mut::<Windows>().primary_mut().set_resolution(
            width as f32, 
            height as f32
        );
        
        // Update systems to apply layout changes
        app.update();
        
        // Capture and compare
        let reference_name = format!("menu_{}.png", suffix);
        let visual_test = VisualTest::new(&mut app)
            .capture_full_screen()
            .compare_to_reference(&reference_name);
            
        assert!(visual_test.is_visually_consistent());
    }
}
}

Generating Baseline Images

When developing new UI components, you'll need to generate baseline images:

#![allow(unused)]
fn main() {
#[test]
fn generate_baseline_for_new_component() {
    // Set up test app and component
    // ...
    
    // Generate baseline image instead of comparing
    let visual_test = VisualTest::new(&mut app)
        .capture_entity(component_entity)
        .generate_baseline("new_component.png");
        
    // Verify the baseline was created
    assert!(visual_test.baseline_generated());
}
}

Handling Animations

For components with animations, capture key frames:

#![allow(unused)]
fn main() {
#[test]
fn test_card_draw_animation() {
    // Set up test app
    // ...
    
    // Start animation
    app.world.spawn(DrawCardEvent { ... });
    
    // Capture key frames of the animation
    let frames = [0, 5, 10, 15, 20];
    
    for frame in frames {
        // Advance animation to specific frame
        for _ in 0..frame {
            app.update();
        }
        
        // Capture and compare
        let reference_name = format!("card_draw_frame_{}.png", frame);
        let visual_test = VisualTest::new(&mut app)
            .capture_entity(card_entity)
            .compare_to_reference(&reference_name);
            
        assert!(visual_test.is_visually_consistent());
    }
}
}

Configuring Test Thresholds

Adjust tolerance thresholds for different components:

#![allow(unused)]
fn main() {
// Text rendering may vary slightly across platforms
let text_test = VisualTest::new(&mut app)
    .capture_entity(text_entity)
    .compare_to_reference("card_text.png")
    .with_tolerance(0.03);  // 3% tolerance

// Card art should be pixel-perfect
let art_test = VisualTest::new(&mut app)
    .capture_entity(art_entity)
    .compare_to_reference("card_art.png")
    .with_tolerance(0.0);  // 0% tolerance
}

Reporting and Debugging

When visual tests fail, detailed reports help identify the issue:

#![allow(unused)]
fn main() {
let result = visual_test.run();
if !result.passed {
    // Generate detailed report
    result.generate_report("visual_test_report");
    
    // Output difference statistics
    println!("Pixel difference: {}%", result.difference_percentage);
    println!("Most different area: {:?}", result.most_different_region);
    
    // Save difference image
    result.save_difference_image("difference.png");
}
}

Integration with CI/CD

Visual regression tests can be integrated into CI/CD pipelines:

# .github/workflows/visual-tests.yml
visual-tests:
  runs-on: ubuntu-latest
  steps:
    - uses: actions/checkout@v2
    - name: Set up Rust
      uses: actions-rs/toolchain@v1
      with:
        toolchain: stable
    - name: Install dependencies
      run: |
        sudo apt-get update
        sudo apt-get install -y libxcb-shape0-dev libxcb-xfixes0-dev
    - name: Run visual regression tests
      run: cargo test --package rummage --test visual_tests
    - name: Upload difference images on failure
      if: failure()
      uses: actions/upload-artifact@v2
      with:
        name: visual-diff-images
        path: target/visual-test-output/

Best Practices

  1. Keep baseline images in version control to track intentional visual changes
  2. Test across different themes (light/dark mode)
  3. Use appropriate tolerances for different components
  4. Set up CI/CD integration to catch visual regressions early
  5. Test across different screen resolutions to ensure responsive design works
  6. Include visual tests in your development workflow

Troubleshooting

Common Issues

  1. Platform differences: Different operating systems may render text slightly differently
  2. Resolution variations: High-DPI displays may produce different pixel counts
  3. Color profile differences: Different monitors may display colors differently

Solutions

  1. Use tolerance thresholds appropriate to the component
  2. Normalize rendering environment in CI/CD
  3. Implement platform-specific baseline images when necessary

Card Systems Integration

This document provides a detailed explanation of how the Game UI system integrates with the Card Systems in Rummage.

Table of Contents

  1. Overview
  2. Visualization Pipeline
  3. Interaction Translation
  4. Special Card Rendering
  5. Implementation Examples

Overview

The Game UI and Card Systems modules work together to create a seamless player experience. The Card Systems module provides the data model and game logic for cards, while the Game UI module translates this data into visual elements and player interactions.

This integration follows these key principles:

  1. Separation of Concerns: Card data and logic remain in the Card Systems module, while visualization and interaction are handled by the Game UI
  2. Event-Driven Communication: Changes in card state trigger events that the UI responds to
  3. Bidirectional Flow: Player interactions with the UI generate events that affect the underlying card systems
  4. Visual Consistency: Card rendering maintains a consistent visual language across different card types and states

Visualization Pipeline

The visualization pipeline transforms card data from the Card Systems module into visual elements in the Game UI:

Card Entity Mapping

Each card entity in the game engine has a corresponding visual entity in the UI:

#![allow(unused)]
fn main() {
fn create_card_visuals(
    mut commands: Commands,
    card_query: Query<(Entity, &Card, &CardName, &CardType), Added<Card>>,
    asset_server: Res<AssetServer>,
) {
    for (entity, card, name, card_type) in &card_query {
        // Create visual entity linked to the card entity
        let visual_entity = commands.spawn((
            // Visual components
            CardVisual { card_entity: entity },
            Sprite {
                custom_size: Some(Vec2::new(CARD_WIDTH, CARD_HEIGHT)),
                ..default()
            },
            SpriteSheetBundle {
                texture: asset_server.load(&get_card_texture_path(card)),
                ..default()
            },
            // UI interaction components
            Interactable,
            Draggable,
            // Transform for positioning
            Transform::default(),
            GlobalTransform::default(),
        )).id();
        
        // Link the card entity to its visual representation
        commands.entity(entity).insert(VisualRepresentation(visual_entity));
    }
}
}

State Change Propagation

When card state changes in the game engine, these changes are reflected in the UI:

#![allow(unused)]
fn main() {
fn update_card_visuals(
    mut commands: Commands,
    card_query: Query<(Entity, &VisualRepresentation, &ZoneType, Option<&Tapped>), Changed<ZoneType>>,
    mut visual_query: Query<(&mut Transform, &mut Visibility)>,
) {
    for (card_entity, visual_rep, zone, tapped) in &card_query {
        if let Ok((mut transform, mut visibility)) = visual_query.get_mut(visual_rep.0) {
            // Update position based on zone
            match zone {
                ZoneType::Battlefield => {
                    visibility.is_visible = true;
                    // Position on battlefield
                },
                ZoneType::Hand => {
                    visibility.is_visible = true;
                    // Position in hand
                },
                ZoneType::Library | ZoneType::Graveyard | ZoneType::Exile => {
                    // Position in appropriate zone
                },
                _ => {
                    visibility.is_visible = false;
                }
            }
            
            // Update rotation based on tapped state
            if tapped.is_some() {
                transform.rotation = Quat::from_rotation_z(std::f32::consts::FRAC_PI_2);
            } else {
                transform.rotation = Quat::default();
            }
        }
    }
}
}

Visual Effects

The UI adds visual effects to represent game actions:

#![allow(unused)]
fn main() {
fn add_cast_visual_effect(
    mut commands: Commands,
    mut cast_events: EventReader<CastSpellEvent>,
    card_query: Query<&VisualRepresentation>,
) {
    for event in cast_events.iter() {
        if let Ok(visual_rep) = card_query.get(event.card) {
            // Add casting visual effect to the card
            commands.entity(visual_rep.0).insert(CastingEffect {
                duration: 0.5,
                elapsed: 0.0,
            });
        }
    }
}
}

Interaction Translation

The UI translates player interactions into game actions:

Drag and Drop

#![allow(unused)]
fn main() {
fn handle_card_drag_end(
    mut commands: Commands,
    mut drag_events: EventReader<DragEndEvent>,
    card_visuals: Query<&CardVisual>,
    zones: Query<(Entity, &ZoneType, &DropTarget)>,
    mut zone_transfer_events: EventWriter<ZoneTransferEvent>,
) {
    for event in drag_events.iter() {
        if let Ok(card_visual) = card_visuals.get(event.entity) {
            // Find the zone the card was dropped on
            for (zone_entity, zone_type, drop_target) in &zones {
                if drop_target.contains(event.position) {
                    // Send zone transfer event
                    zone_transfer_events.send(ZoneTransferEvent {
                        card: card_visual.card_entity,
                        target_zone: zone_entity,
                        target_zone_type: *zone_type,
                    });
                    break;
                }
            }
        }
    }
}
}

Card Selection

#![allow(unused)]
fn main() {
fn handle_card_selection(
    mut commands: Commands,
    mut click_events: EventReader<ClickEvent>,
    card_visuals: Query<&CardVisual>,
    mut selection_events: EventWriter<CardSelectionEvent>,
) {
    for event in click_events.iter() {
        if let Ok(card_visual) = card_visuals.get(event.entity) {
            // Send selection event
            selection_events.send(CardSelectionEvent {
                card: card_visual.card_entity,
                selection_type: if event.button == MouseButton::Left {
                    SelectionType::Primary
                } else {
                    SelectionType::Secondary
                },
            });
        }
    }
}
}

Context Menus

#![allow(unused)]
fn main() {
fn show_card_context_menu(
    mut commands: Commands,
    mut right_click_events: EventReader<RightClickEvent>,
    card_visuals: Query<&CardVisual>,
    cards: Query<(&Card, &CardType, &ZoneType)>,
) {
    for event in right_click_events.iter() {
        if let Ok(card_visual) = card_visuals.get(event.entity) {
            if let Ok((card, card_type, zone)) = cards.get(card_visual.card_entity) {
                // Create context menu based on card type and zone
                let menu_entity = commands.spawn((
                    ContextMenu {
                        card: card_visual.card_entity,
                        position: event.screen_position,
                    },
                    // UI components for the menu
                )).id();
                
                // Add appropriate actions based on card type and zone
                add_context_menu_actions(
                    &mut commands, 
                    menu_entity, 
                    card_visual.card_entity, 
                    card_type, 
                    zone
                );
            }
        }
    }
}
}

Special Card Rendering

Some cards require special rendering treatment:

#![allow(unused)]
fn main() {
fn show_modal_card_options(
    mut commands: Commands,
    mut cast_events: EventReader<CastModalSpellEvent>,
    cards: Query<&ModalOptions>,
) {
    for event in cast_events.iter() {
        if let Ok(modal_options) = cards.get(event.card) {
            // Create modal selection UI
            let modal_ui = commands.spawn((
                ModalSelectionUI {
                    card: event.card,
                    options: modal_options.options.clone(),
                },
                // UI components
            )).id();
            
            // Add option buttons
            for (i, option) in modal_options.options.iter().enumerate() {
                commands.spawn((
                    ModalOptionButton {
                        parent: modal_ui,
                        option_index: i,
                    },
                    // UI components
                    // Text component with option description
                ));
            }
        }
    }
}
}

Split Cards

#![allow(unused)]
fn main() {
fn render_split_card(
    mut commands: Commands,
    split_cards: Query<(Entity, &SplitCard, &VisualRepresentation)>,
    mut textures: ResMut<Assets<TextureAtlas>>,
    asset_server: Res<AssetServer>,
) {
    for (entity, split_card, visual_rep) in &split_cards {
        // Load both halves of the split card
        let left_texture = asset_server.load(&split_card.left_half_texture);
        let right_texture = asset_server.load(&split_card.right_half_texture);
        
        // Create a special sprite that combines both halves
        commands.entity(visual_rep.0).insert(SplitCardVisual {
            left_half: left_texture,
            right_half: right_texture,
        });
    }
}
}

Implementation Examples

Card Hover Preview

#![allow(unused)]
fn main() {
fn card_hover_preview(
    mut commands: Commands,
    hover_events: EventReader<HoverEvent>,
    card_visuals: Query<&CardVisual>,
    cards: Query<(&Card, &CardName)>,
    mut preview_query: Query<Entity, With<CardPreview>>,
) {
    // Remove existing previews
    for preview_entity in &preview_query {
        commands.entity(preview_entity).despawn_recursive();
    }
    
    // Create new preview for hovered card
    for event in hover_events.iter() {
        if let Ok(card_visual) = card_visuals.get(event.entity) {
            if let Ok((card, name)) = cards.get(card_visual.card_entity) {
                commands.spawn((
                    CardPreview,
                    // Large card image
                    ImageBundle {
                        image: UiImage::new(asset_server.load(&get_card_preview_texture(card))),
                        style: Style {
                            position_type: PositionType::Absolute,
                            position: UiRect {
                                left: Val::Px(event.screen_position.x + 20.0),
                                top: Val::Px(event.screen_position.y - 200.0),
                                ..default()
                            },
                            size: Size::new(Val::Px(240.0), Val::Px(336.0)),
                            ..default()
                        },
                        ..default()
                    },
                ));
            }
        }
    }
}
}

Battlefield Layout

#![allow(unused)]
fn main() {
fn organize_battlefield_cards(
    mut commands: Commands,
    players: Query<(Entity, &Player)>,
    battlefield: Query<Entity, With<BattlefieldZone>>,
    cards_in_battlefield: Query<(Entity, &VisualRepresentation), (With<Card>, With<ZoneType>)>,
    mut card_visuals: Query<&mut Transform>,
) {
    if let Ok(battlefield_entity) = battlefield.get_single() {
        // Group cards by controller
        let mut player_cards: HashMap<Entity, Vec<Entity>> = HashMap::new();
        
        for (card_entity, visual_rep) in &cards_in_battlefield {
            if let Some(controller) = get_card_controller(card_entity) {
                player_cards.entry(controller)
                    .or_insert_with(Vec::new)
                    .push(visual_rep.0);
            }
        }
        
        // Position each player's cards in their battlefield section
        for (player_entity, player) in &players {
            if let Some(visual_entities) = player_cards.get(&player_entity) {
                let player_section = get_player_battlefield_section(player_entity, &players);
                
                for (i, &visual_entity) in visual_entities.iter().enumerate() {
                    if let Ok(mut transform) = card_visuals.get_mut(visual_entity) {
                        // Calculate position within player's section
                        let row = i / CARDS_PER_ROW;
                        let col = i % CARDS_PER_ROW;
                        
                        transform.translation = Vec3::new(
                            player_section.min.x + col as f32 * (CARD_WIDTH + CARD_SPACING),
                            player_section.min.y + row as f32 * (CARD_HEIGHT + CARD_SPACING),
                            0.0,
                        );
                    }
                }
            }
        }
    }
}
}

These examples demonstrate the tight integration between the Game UI and Card Systems modules, showing how they work together to create a cohesive player experience while maintaining a clean separation of concerns.


For more information on the Card Systems module, see the Card Systems documentation.

Network Integration

Networking Documentation

This section outlines the implementation of multiplayer functionality for the Rummage MTG Commander game engine using bevy_replicon.

Table of Contents

  1. Overview
  2. Key Components
  3. Implementation Status
  4. Recent Updates
  5. Getting Started
  6. Future Enhancements

Overview

The networking system enables multiplayer gameplay with features like state synchronization, lobby management, and rollback mechanisms for handling network disruptions. The implementation is built on bevy_replicon, providing a robust foundation for networked gameplay.

For a comprehensive overview, see the Core Architecture Overview.

Key Components

The networking system consists of the following major components:

Core Networking

Lobby System

Gameplay Networking

Testing

Security

Implementation Status

This documentation represents the design and implementation of the networking system. Components are marked as follows:

ComponentStatusDescription
Core Network ArchitectureBasic network architecture using bevy_replicon
Client-Server CommunicationBasic client-server messaging
Lobby System🔄System for creating and joining game lobbies
Game State Synchronization🔄Synchronizing game state across the network
RNG SynchronizationMaintaining consistent random number generation
Rollback SystemRecovery from network disruptions
Replicon IntegrationIntegration with bevy_replicon for entity replication
Auth System⚠️Player authentication and authorization
Anti-Cheat⚠️Measures to prevent cheating
Hidden Information🔄Managing information that should be hidden from certain players
Chat System⚠️In-game communication
Spectator Mode⚠️Support for non-player observers

Legend:

  • ✅ Implemented and tested
  • 🔄 In progress
  • ⚠️ Planned but not yet implemented

Recent Updates

Recent updates to the networking documentation include:

  • Replicon Integration: Added detailed documentation for integrating bevy_replicon with our RNG state management system
  • Rollback Mechanism: Enhanced documentation of rollback mechanisms for handling network disruptions
  • Test Framework: Expanded testing documentation with new test cases for RNG synchronization
  • Performance Considerations: Added guidance on optimizing network performance

Getting Started

To begin working on the networking implementation:

  1. Review the Core Architecture Overview
  2. Understand the Implementation Details
  3. Set up a local development environment with bevy_replicon
  4. Start with the basic client-server connectivity
  5. Incrementally add features following the implementation plan

Future Enhancements

In future versions, we plan to enhance the networking implementation with:

  • Spectator Mode: Allow non-players to watch games in progress
  • Replay System: Record and replay games for analysis or sharing
  • Tournament Support: Special features for organizing and running tournaments
  • Cross-Platform Play: Ensure compatibility across different platforms
  • Advanced Anti-Cheat: More sophisticated measures to prevent cheating
  • Voice Chat Integration: In-game communication between players

This documentation will evolve as the networking implementation progresses. Check back regularly for updates and additional details.

Networking Implementation with bevy_replicon

This document outlines the architecture and implementation details for adding multiplayer networking support to our MTG Commander game engine using bevy_replicon.

Table of Contents

  1. Overview
  2. Setup and Dependencies
  3. Architecture
  4. Server Implementation
  5. Client Implementation
  6. Replication Strategy
  7. Game State Synchronization
  8. Networking Events
  9. Security Considerations
  10. Testing and Debugging

Overview

bevy_replicon is a high-level networking library built on top of bevy_renet that provides a client-server replication system for Bevy. It handles entity replication, RPC (Remote Procedure Calls), and event synchronization between the server and connected clients.

Our implementation will focus on creating a robust, secure, and efficient networking layer that supports the complex state management required for MTG Commander games while maintaining the game rules integrity.

Setup and Dependencies

Add the following dependencies to your Cargo.toml file:

[dependencies]
# Existing dependencies...
bevy_replicon = "0.15.0"
bevy_quinnet = { version = "0.6.0", optional = true }
serde = { version = "1.0", features = ["derive"] }
bincode = "1.3"

Architecture

Our networking architecture follows a client-server model:

  1. Server: Maintains authoritative game state, handles game logic, and broadcasts state changes to clients
  2. Clients: Connect to the server, send player actions, and render game state received from the server

Key Components:

  • NetworkingPlugin: Initializes all networking systems and components
  • ServerPlugin: Handles server-specific logic when running as a server
  • ClientPlugin: Handles client-specific logic when running as a client
  • ReplicationSet: Defines which components should be replicated from server to clients
  • NetworkedActions: Server-validated actions that clients can request

Server Implementation

The server is the authoritative source of truth for game state and handles:

  1. Game session creation and management
  2. Player connections and authentication
  3. Processing game actions and maintaining game rules
  4. Broadcasting state updates to connected clients
#![allow(unused)]
fn main() {
// src/networking/server.rs
use bevy::prelude::*;
use bevy_replicon::prelude::*;
use crate::game_engine::GameAction;

pub struct ServerPlugin;

impl Plugin for ServerPlugin {
    fn build(&self, app: &mut App) {
        app
            .add_plugins(bevy_replicon::prelude::ServerPlugin::default())
            .add_systems(Startup, setup_server)
            .add_systems(Update, (
                handle_player_connections,
                process_action_requests,
                validate_game_state,
                broadcast_game_events,
            ));
    }
}

fn setup_server(mut commands: Commands) {
    // Initialize server resources
    commands.insert_resource(GameServer::new());
    
    // Add server-specific systems and resources
    // ...
}

fn handle_player_connections(
    mut commands: Commands,
    mut server: ResMut<Server>,
    mut connection_events: EventReader<ConnectionEvent>,
    mut clients: ResMut<Clients>,
) {
    // Handle new player connections and disconnections
    // ...
}

fn process_action_requests(
    mut commands: Commands,
    mut server: ResMut<Server>,
    mut action_requests: EventReader<ClientActionRequest>,
    game_state: Res<GameState>,
) {
    // Process and validate client action requests
    // ...
}
}

Client Implementation

The client is responsible for:

  1. Connecting to the server
  2. Sending player inputs and action requests
  3. Rendering the replicated game state
  4. Providing feedback for connection status
#![allow(unused)]
fn main() {
// src/networking/client.rs
use bevy::prelude::*;
use bevy_replicon::prelude::*;
use crate::game_engine::GameAction;

pub struct ClientPlugin;

impl Plugin for ClientPlugin {
    fn build(&self, app: &mut App) {
        app
            .add_plugins(bevy_replicon::prelude::ClientPlugin::default())
            .add_systems(Startup, setup_client)
            .add_systems(Update, (
                handle_connection_status,
                send_player_actions,
                apply_server_updates,
            ));
    }
}

fn setup_client(mut commands: Commands) {
    // Initialize client resources
    commands.insert_resource(GameClient::new());
    
    // Add client-specific systems and resources
    // ...
}

fn handle_connection_status(
    mut connection_events: EventReader<ConnectionEvent>,
    mut client_state: ResMut<ClientState>,
) {
    // Update UI based on connection status
    // ...
}

fn send_player_actions(
    mut client: ResMut<Client>,
    mut action_queue: ResMut<ActionQueue>,
    input: Res<Input<KeyCode>>,
) {
    // Send player actions to the server
    // ...
}
}

Replication Strategy

We need to carefully consider which components should be replicated and how to maintain game state integrity.

Server-to-Client Replication:

#![allow(unused)]
fn main() {
// src/networking/replication.rs
use bevy::prelude::*;
use bevy_replicon::prelude::*;
use crate::card::Card;
use crate::player::Player;
use crate::game_engine::{GameState, Phase, PrioritySystem};

pub fn register_replication_components(app: &mut App) {
    app
        // Register components that should be replicated
        .replicate::<Card>()
        .replicate::<Player>()
        .replicate::<GameState>()
        .replicate::<Phase>()
        .replicate::<PrioritySystem>()
        // Register custom events for replication
        .replicate_event::<GameAction>();
}

#[derive(Component, Serialize, Deserialize, Default)]
pub struct NetworkedEntity;

#[derive(Component, Serialize, Deserialize)]
pub struct OwnerOnly {
    pub player_id: u64,
}
}

Game State Synchronization

MTG requires careful synchronization of complex game states:

Card Visibility and Privacy:

#![allow(unused)]
fn main() {
// Example of handling card visibility for networked games
// src/networking/visibility.rs
use bevy::prelude::*;
use bevy_replicon::prelude::*;
use crate::card::Card;
use crate::game_engine::zones::{Zone, ZoneType};

// Components for visibility control
#[derive(Component)]
pub struct VisibleTo {
    pub player_ids: Vec<u64>,
}

// System to update card visibility based on game rules
fn update_card_visibility(
    mut commands: Commands,
    mut cards: Query<(Entity, &Card, &Zone, Option<&VisibleTo>)>,
    players: Query<(Entity, &Player)>,
) {
    for (entity, card, zone, visible_to) in &mut cards {
        match zone.zone_type {
            ZoneType::Hand => {
                // Only visible to owner
                let owner_id = card.owner.id();
                commands.entity(entity).insert(VisibleTo {
                    player_ids: vec![owner_id],
                });
            },
            ZoneType::Battlefield => {
                // Visible to all players
                commands.entity(entity).remove::<VisibleTo>();
            },
            // Handle other zone types...
        }
    }
}
}

Networking Events

Define custom events for networking-related actions:

#![allow(unused)]
fn main() {
// src/networking/events.rs
use bevy::prelude::*;
use serde::{Serialize, Deserialize};

#[derive(Event, Serialize, Deserialize, Clone, Debug)]
pub struct ClientActionRequest {
    pub player_id: u64,
    pub action_type: NetworkedActionType,
    pub targets: Vec<Entity>,
    // Additional parameters specific to the action type
}

#[derive(Serialize, Deserialize, Clone, Debug)]
pub enum NetworkedActionType {
    PlayLand,
    CastSpell,
    ActivateAbility { ability_index: usize },
    PassPriority,
    MulliganDecision { keep: bool },
    CommanderZoneChoice { to_command_zone: bool },
    DeclareAttackers { attackers: Vec<Entity> },
    DeclareBlockers { blockers: Vec<(Entity, Entity)> },
    // Additional action types as needed
}

// Implement server-to-client events for game updates
#[derive(Event, Serialize, Deserialize, Clone, Debug)]
pub struct GameStateUpdate {
    pub active_player: Entity,
    pub phase: Phase,
    pub priority_player: Option<Entity>,
    // Additional state information
}
}

Security Considerations

Security is crucial for card games to prevent cheating:

  1. Server Authority: The server is the sole authority for game state. All client actions must be validated by the server.

  2. Action Validation: Each client action must be validated against the current game state and rules.

  3. Anti-Cheat Measures:

    • Hidden information (hand cards) should only be sent to the appropriate player
    • Random events (shuffling, coin flips) should be performed on the server
    • Rate-limiting client requests to prevent DoS attacks
  4. Reconnection Handling: Players should be able to reconnect to games in progress.

Testing and Debugging

For effective testing and debugging of the networking implementation:

  1. Local Testing: Simulate a networked environment on a single machine
#![allow(unused)]
fn main() {
// src/networking/testing.rs
pub fn setup_local_test_environment(app: &mut App) {
    app
        .add_plugins(ServerPlugin)
        .add_plugins(ClientPlugin)
        .add_systems(Startup, spawn_test_clients);
}
}
  1. Integration Tests: Dedicated tests for network functionality

  2. Network Condition Simulation: Test under various network conditions (latency, packet loss)

  3. Logging and Monitoring: Comprehensive logging of network events

#![allow(unused)]
fn main() {
fn log_network_events(
    connection_events: EventReader<ConnectionEvent>,
    client_events: EventReader<ClientActionRequest>,
    server_events: EventReader<GameStateUpdate>,
) {
    for event in connection_events.read() {
        info!("Connection event: {:?}", event);
    }
    
    // Log other events...
}
}

This document provides a high-level overview of implementing networking with bevy_replicon for the MTG Commander game engine. Implementation details will need to be adjusted based on specific game requirements and the evolving architecture of the game.

Connection Management

Data Synchronization

Error Handling

bevy_replicon Implementation Details

This document provides detailed implementation guidelines for integrating bevy_replicon into our MTG Commander game engine.

Table of Contents

  1. Project Structure
  2. Core Components
  3. Server Implementation
  4. Client Implementation
  5. Serialization Strategy
  6. Game-Specific Replication
  7. Testing Strategy
  8. Random Number Generator Synchronization

Project Structure

The networking implementation will be structured as follows:

src/
└── networking/
    ├── mod.rs               # Module exports and plugin registration
    ├── plugin.rs            # Main NetworkingPlugin
    ├── server/
    │   ├── mod.rs           # Server module exports
    │   ├── plugin.rs        # ServerPlugin implementation
    │   ├── systems.rs       # Server systems
    │   ├── events.rs        # Server-specific events
    │   └── resources.rs     # Server resources
    ├── client/
    │   ├── mod.rs           # Client module exports
    │   ├── plugin.rs        # ClientPlugin implementation
    │   ├── systems.rs       # Client systems
    │   ├── events.rs        # Client-specific events
    │   └── resources.rs     # Client resources
    ├── replication/
    │   ├── mod.rs           # Replication module exports
    │   ├── components.rs    # Replicable components
    │   ├── registry.rs      # Component and event registration
    │   └── visibility.rs    # Visibility control
    ├── protocol/
    │   ├── mod.rs           # Protocol module exports
    │   ├── actions.rs       # Networked actions
    │   └── messages.rs      # Custom messages
    └── testing/
        ├── mod.rs           # Testing module exports
        ├── simulation.rs    # Network simulation
        └── diagnostics.rs   # Diagnostics tools

Core Components

Networking Plugin

#![allow(unused)]
fn main() {
// src/networking/plugin.rs
use bevy::prelude::*;
use crate::networking::server::ServerPlugin;
use crate::networking::client::ClientPlugin;
use crate::networking::replication::ReplicationPlugin;

/// Configuration for the networking plugin
#[derive(Resource)]
pub struct NetworkingConfig {
    /// Whether this instance is running as a server
    pub is_server: bool,
    /// Whether this instance is running as a client
    pub is_client: bool,
    /// Server address to connect to (client only)
    pub server_address: Option<String>,
    /// Server port to host on (server) or connect to (client)
    pub port: u16,
    /// Maximum number of clients that can connect (server only)
    pub max_clients: usize,
}

impl Default for NetworkingConfig {
    fn default() -> Self {
        Self {
            is_server: false,
            is_client: true,
            server_address: None,
            port: 5000,
            max_clients: 4,
        }
    }
}

/// Main plugin for networking functionality
pub struct NetworkingPlugin;

impl Plugin for NetworkingPlugin {
    fn build(&self, app: &mut App) {
        // Add core networking resources
        app.init_resource::<NetworkingConfig>();
        
        // Add replication plugin
        app.add_plugins(ReplicationPlugin);
        
        // Add server or client plugins based on configuration
        let config = app.world.resource::<NetworkingConfig>();
        
        if config.is_server {
            app.add_plugins(ServerPlugin);
        }
        
        if config.is_client {
            app.add_plugins(ClientPlugin);
        }
    }
}
}

Server Implementation

Server Resource

#![allow(unused)]
fn main() {
// src/networking/server/resources.rs
use bevy::prelude::*;
use bevy_replicon::prelude::*;
use std::collections::HashMap;

/// Resource for managing the server state
#[derive(Resource)]
pub struct GameServer {
    /// Maps client IDs to player entities
    pub client_player_map: HashMap<ClientId, Entity>,
    /// Maps player entities to client IDs
    pub player_client_map: HashMap<Entity, ClientId>,
    /// Current game session ID
    pub session_id: String,
    /// Whether the server is accepting new connections
    pub accepting_connections: bool,
    /// Server status
    pub status: ServerStatus,
}

/// Server status
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ServerStatus {
    /// Server is starting up
    Starting,
    /// Server is waiting for players to connect
    WaitingForPlayers,
    /// Game is in progress
    GameInProgress,
    /// Game has ended
    GameEnded,
}

impl Default for GameServer {
    fn default() -> Self {
        Self {
            client_player_map: HashMap::new(),
            player_client_map: HashMap::new(),
            session_id: uuid::Uuid::new_v4().to_string(),
            accepting_connections: true,
            status: ServerStatus::Starting,
        }
    }
}
}

Server Systems

#![allow(unused)]
fn main() {
// src/networking/server/systems.rs
use bevy::prelude::*;
use bevy_replicon::prelude::*;
use crate::networking::server::resources::*;
use crate::networking::protocol::actions::*;
use crate::game_engine::GameAction;

/// Set up server resources
pub fn setup_server(mut commands: Commands) {
    commands.insert_resource(GameServer::default());
}

/// Handle player connections and disconnections
pub fn handle_player_connections(
    mut commands: Commands,
    mut server: ResMut<GameServer>,
    mut server_events: EventReader<ServerEvent>,
    mut connected_clients: ResMut<ConnectedClients>,
) {
    for event in server_events.read() {
        match event {
            ServerEvent::ClientConnected { client_id } => {
                info!("Client connected: {:?}", client_id);
                
                // Create player entity for the client
                if server.accepting_connections {
                    let player_entity = commands.spawn_empty().id();
                    
                    // Map client to player
                    server.client_player_map.insert(*client_id, player_entity);
                    server.player_client_map.insert(player_entity, *client_id);
                    
                    // Start replication for this client
                    commands.add(StartReplication {
                        client_id: *client_id,
                    });
                    
                    // Add player to replicated clients list
                    commands.entity(player_entity).insert(ReplicatedClient {
                        client_id: *client_id,
                    });
                }
            }
            ServerEvent::ClientDisconnected { client_id, reason } => {
                info!("Client disconnected: {:?}, reason: {:?}", client_id, reason);
                
                // Remove player entity and mappings
                if let Some(player_entity) = server.client_player_map.remove(client_id) {
                    server.player_client_map.remove(&player_entity);
                    
                    // Handle player disconnection in game logic
                    // (e.g., mark player as AFK, save their state for reconnection, etc.)
                }
            }
        }
    }
}

/// Process action requests from clients
pub fn process_action_requests(
    mut commands: Commands,
    mut action_requests: EventReader<ClientActionRequest>,
    server: Res<GameServer>,
    game_state: Res<crate::game_engine::state::GameState>,
    mut game_actions: EventWriter<GameAction>,
) {
    for request in action_requests.read() {
        // Validate client ID to player mapping
        if let Some(player_entity) = server.client_player_map.get(&request.client_id) {
            match &request.action {
                NetworkedAction::PlayLand { card_id } => {
                    // Validate action against game rules
                    if game_state.can_play_land(*player_entity) {
                        // Convert to game action
                        game_actions.send(GameAction::PlayLand {
                            player: *player_entity,
                            land_card: *card_id,
                        });
                    }
                }
                NetworkedAction::CastSpell { card_id, targets, mana_payment } => {
                    // Validate spell casting
                    if game_state.can_cast_spell(*player_entity, *card_id) {
                        game_actions.send(GameAction::CastSpell {
                            player: *player_entity,
                            spell_card: *card_id,
                            targets: targets.clone(),
                            mana_payment: mana_payment.clone(),
                        });
                    }
                }
                // Handle other action types...
                _ => {}
            }
        }
    }
}
}

Client Implementation

Client Resources

#![allow(unused)]
fn main() {
// src/networking/client/resources.rs
use bevy::prelude::*;
use bevy_replicon::prelude::*;

/// Resource for managing the client state
#[derive(Resource)]
pub struct GameClient {
    /// The client's local player entity
    pub local_player: Option<Entity>,
    /// Local player ID
    pub local_player_id: Option<u64>,
    /// Connection status
    pub connection_status: ConnectionStatus,
    /// Action queue for batching actions
    pub action_queue: Vec<NetworkedAction>,
}

/// Connection status
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ConnectionStatus {
    /// Not connected to a server
    Disconnected,
    /// Attempting to connect to a server
    Connecting,
    /// Connected and authenticated
    Connected,
    /// Connection error occurred
    Error(ConnectionError),
}

/// Connection error types
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ConnectionError {
    /// Failed to connect to the server
    ConnectionFailed,
    /// Connected but authentication failed
    AuthenticationFailed,
    /// Disconnected unexpectedly
    Disconnected,
    /// Timeout waiting for server response
    Timeout,
}

impl Default for GameClient {
    fn default() -> Self {
        Self {
            local_player: None,
            local_player_id: None,
            connection_status: ConnectionStatus::Disconnected,
            action_queue: Vec::new(),
        }
    }
}
}

Client Systems

#![allow(unused)]
fn main() {
// src/networking/client/systems.rs
use bevy::prelude::*;
use bevy_replicon::prelude::*;
use crate::networking::client::resources::*;
use crate::networking::protocol::actions::*;
use crate::player::Player;

/// Set up client resources
pub fn setup_client(mut commands: Commands) {
    commands.insert_resource(GameClient::default());
}

/// Handle connection status changes
pub fn handle_connection_status(
    mut client: ResMut<GameClient>,
    mut client_status: ResMut<RepliconClientStatus>,
) {
    match *client_status {
        RepliconClientStatus::Connecting => {
            client.connection_status = ConnectionStatus::Connecting;
        }
        RepliconClientStatus::Connected => {
            client.connection_status = ConnectionStatus::Connected;
        }
        RepliconClientStatus::Disconnected => {
            client.connection_status = ConnectionStatus::Disconnected;
        }
    }
}

/// Update local player reference
pub fn update_local_player(
    mut client: ResMut<GameClient>,
    players: Query<(Entity, &Player, &ReplicatedClient)>,
    replicon_client: Res<RepliconClient>,
) {
    // Find the player entity that belongs to this client
    if client.local_player.is_none() {
        for (entity, _player, replicated_client) in &players {
            if replicated_client.client_id == replicon_client.id() {
                client.local_player = Some(entity);
                client.local_player_id = Some(replicon_client.id().0);
                break;
            }
        }
    }
}

/// Send actions to the server
pub fn send_player_actions(
    mut client: ResMut<GameClient>,
    mut action_requests: EventWriter<ClientActionRequest>,
    replicon_client: Res<RepliconClient>,
) {
    // Process queued actions
    for action in client.action_queue.drain(..) {
        let request = ClientActionRequest {
            client_id: replicon_client.id(),
            action: action,
        };
        
        action_requests.send(request);
    }
}

/// Handle player input and queue actions
pub fn handle_player_input(
    mut client: ResMut<GameClient>,
    input: Res<Input<KeyCode>>,
    mouse_input: Res<Input<MouseButton>>,
    card_interaction: Res<CardInteraction>,
) {
    // Example: Queue an action based on player input
    if mouse_input.just_pressed(MouseButton::Left) && card_interaction.selected_card.is_some() {
        if input.pressed(KeyCode::ShiftLeft) {
            // Queue a cast spell action
            client.action_queue.push(NetworkedAction::CastSpell {
                card_id: card_interaction.selected_card.unwrap(),
                targets: card_interaction.targets.clone(),
                mana_payment: card_interaction.proposed_payment.clone(),
            });
        } else {
            // Queue a different action based on the card and context
            // ...
        }
    }
}
}

Serialization Strategy

For efficient serialization, we need to implement Serialize/Deserialize traits for all networked components:

#![allow(unused)]
fn main() {
// src/networking/replication/components.rs
use bevy::prelude::*;
use serde::{Serialize, Deserialize};
use crate::game_engine::phase::Phase;
use crate::player::Player;
use crate::mana::Mana;

// Make sure all replicated components implement Serialize and Deserialize
// For complex types, consider custom serialization for efficiency

#[derive(Component, Serialize, Deserialize, Clone, Debug)]
pub struct NetworkedPlayer {
    pub name: String,
    pub life: i32,
    pub mana_pool: Mana,
    // Include only data that needs to be networked
    // Omit any large or game-state-derived data
}

impl From<&Player> for NetworkedPlayer {
    fn from(player: &Player) -> Self {
        Self {
            name: player.name.clone(),
            life: player.life,
            mana_pool: player.mana_pool.clone(),
        }
    }
}

// Custom serialization for Entity references to handle cross-client referencing
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct NetworkedEntity {
    pub id: u64, // Stable ID that can be used across the network
}

// System to maintain Entity <-> NetworkedEntity mappings
pub fn update_entity_mappings(
    mut commands: Commands,
    new_entities: Query<Entity, Added<Replicated>>,
    mut entity_map: ResMut<EntityMap>,
) {
    for entity in &new_entities {
        // Generate stable network ID
        let network_id = entity_map.next_id();
        
        // Save mapping
        entity_map.insert(entity, network_id);
        
        // Add networked ID component
        commands.entity(entity).insert(NetworkedId(network_id));
    }
}
}

Game-Specific Replication

MTG Card Replication

Special care needs to be taken for replicating MTG cards, as they have hidden information:

#![allow(unused)]
fn main() {
// src/networking/replication/visibility.rs
use bevy::prelude::*;
use bevy_replicon::prelude::*;
use crate::card::Card;
use crate::game_engine::zones::{Zone, ZoneType};

// System to update client visibility for cards
pub fn update_card_visibility(
    mut commands: Commands,
    cards: Query<(Entity, &Card, &Zone)>,
    players: Query<(Entity, &ReplicatedClient)>,
    server: Res<RepliconServer>,
    replication_rules: Res<ReplicationRules>,
) {
    for (card_entity, card, zone) in &cards {
        match zone.zone_type {
            ZoneType::Hand => {
                // Only the owner can see cards in hand
                let owner_client_id = get_client_id_for_player(card.owner, &players);
                
                if let Some(owner_id) = owner_client_id {
                    // Use ClientVisibility to control which clients can see this entity
                    commands.entity(card_entity).insert(ClientVisibility {
                        policy: VisibilityPolicy::Blacklist,
                        client_ids: players
                            .iter()
                            .filter_map(|(_, client)| {
                                if client.client_id != owner_id {
                                    Some(client.client_id)
                                } else {
                                    None
                                }
                            })
                            .collect(),
                    });
                }
            },
            ZoneType::Library => {
                // No player can see library cards (except the top in some cases)
                commands.entity(card_entity).insert(ClientVisibility {
                    policy: VisibilityPolicy::Blacklist,
                    client_ids: players
                        .iter()
                        .map(|(_, client)| client.client_id)
                        .collect(),
                });
            },
            ZoneType::Battlefield => {
                // All players can see battlefield cards
                commands.entity(card_entity).remove::<ClientVisibility>();
            },
            ZoneType::Graveyard | ZoneType::Exile | ZoneType::Command => {
                // All players can see these zones
                commands.entity(card_entity).remove::<ClientVisibility>();
            },
            // Handle other zones...
        }
    }
}

// Helper function to get client ID for a player entity
fn get_client_id_for_player(
    player_entity: Entity,
    players: &Query<(Entity, &ReplicatedClient)>,
) -> Option<ClientId> {
    players
        .iter()
        .find_map(|(entity, client)| {
            if entity == player_entity {
                Some(client.client_id)
            } else {
                None
            }
        })
}
}

Testing Strategy

Network Simulation

#![allow(unused)]
fn main() {
// src/networking/testing/simulation.rs
use bevy::prelude::*;
use crate::networking::plugin::NetworkingPlugin;
use crate::networking::server::ServerPlugin;
use crate::networking::client::ClientPlugin;

/// Plugin for testing the networking in a local environment
pub struct NetworkTestPlugin;

impl Plugin for NetworkTestPlugin {
    fn build(&self, app: &mut App) {
        // Create a local server and client
        app.insert_resource(NetworkingConfig {
            is_server: true, // This instance acts as both server and client
            is_client: true,
            server_address: Some("127.0.0.1".to_string()),
            port: 5000,
            max_clients: 4,
        });
        
        app.add_plugins(NetworkingPlugin);
        
        // Add systems for simulating network conditions
        app.add_systems(Update, simulate_network_conditions);
    }
}

/// System to simulate various network conditions for testing
pub fn simulate_network_conditions(
    mut server: ResMut<RepliconServer>,
    mut client: ResMut<RepliconClient>,
    network_simulation: Res<NetworkSimulation>,
) {
    // Simulate latency
    if let Some(latency) = network_simulation.latency {
        // Delay processing of messages
        std::thread::sleep(std::time::Duration::from_millis(latency));
    }
    
    // Simulate packet loss
    if let Some(packet_loss) = network_simulation.packet_loss {
        let mut rng = rand::thread_rng();
        if rng.gen::<f32>() < packet_loss {
            // Simulate packet loss by not processing some messages
            // This would require modifications to the underlying transport layer
        }
    }
}

/// Resource for configuring network simulation
#[derive(Resource)]
pub struct NetworkSimulation {
    /// Simulated latency in milliseconds
    pub latency: Option<u64>,
    /// Packet loss rate (0.0 to 1.0)
    pub packet_loss: Option<f32>,
    /// Jitter in milliseconds
    pub jitter: Option<u64>,
}

impl Default for NetworkSimulation {
    fn default() -> Self {
        Self {
            latency: None,
            packet_loss: None,
            jitter: None,
        }
    }
}
}

Integration with Game Loop

#![allow(unused)]
fn main() {
// src/networking/integration.rs
use bevy::prelude::*;
use crate::networking::plugin::NetworkingPlugin;
use crate::game_engine::state::GameState;

/// System to initialize networking based on game mode
pub fn initialize_networking(
    mut commands: Commands,
    game_state: Res<GameState>,
    mut app_config: ResMut<NetworkingConfig>,
) {
    match game_state.mode {
        GameMode::SinglePlayer => {
            // No networking needed
            app_config.is_server = false;
            app_config.is_client = false;
        },
        GameMode::HostMultiplayer => {
            // Host acts as both server and client
            app_config.is_server = true;
            app_config.is_client = true;
            app_config.server_address = Some("0.0.0.0".to_string()); // Bind to all interfaces
        },
        GameMode::JoinMultiplayer { server_address } => {
            // Client-only mode
            app_config.is_server = false;
            app_config.is_client = true;
            app_config.server_address = Some(server_address.clone());
        }
    }
}
}

Random Number Generator Synchronization

Overview

Deterministic random number generation is critical for multiplayer games to ensure that all clients produce identical results when processing the same game actions. This section outlines how to use bevy_rand and bevy_prng to maintain synchronized RNG state across network boundaries.

#![allow(unused)]
fn main() {
// src/networking/rng/plugin.rs
use bevy::prelude::*;
use bevy_prng::WyRand;
use bevy_rand::prelude::*;
use crate::networking::server::resources::GameServer;

/// Plugin for handling RNG synchronization in networked games
pub struct NetworkedRngPlugin;

impl Plugin for NetworkedRngPlugin {
    fn build(&self, app: &mut App) {
        // Register the WyRand PRNG with bevy_rand
        app.add_plugins(EntropyPlugin::<WyRand>::default())
            .add_systems(Update, synchronize_rng_state)
            .add_systems(PostUpdate, handle_rng_state_replication);
    }
}

/// Resource to track the RNG state for replication
#[derive(Resource)]
pub struct RngStateTracker {
    /// The current state of the global RNG
    pub global_state: Vec<u8>,
    /// Last synchronization timestamp
    pub last_sync: f32,
    /// Whether the RNG state has changed since last sync
    pub dirty: bool,
}

impl Default for RngStateTracker {
    fn default() -> Self {
        Self {
            global_state: Vec::new(),
            last_sync: 0.0,
            dirty: false,
        }
    }
}

/// System to capture RNG state for replication
pub fn synchronize_rng_state(
    mut rng: GlobalEntropy<WyRand>,
    mut state_tracker: ResMut<RngStateTracker>,
    time: Res<Time>,
) {
    // Only sync periodically to reduce network traffic
    if time.elapsed_seconds() - state_tracker.last_sync > 5.0 {
        // Serialize the RNG state
        if let Some(serialized_state) = rng.try_serialize_state() {
            state_tracker.global_state = serialized_state;
            state_tracker.last_sync = time.elapsed_seconds();
            state_tracker.dirty = true;
        }
    }
}

/// System to handle replication of RNG state to clients
pub fn handle_rng_state_replication(
    server: Option<Res<GameServer>>,
    rng_state: Res<RngStateTracker>,
    mut client: ResMut<RepliconClient>,
    mut server_res: ResMut<RepliconServer>,
) {
    // Only the server should send RNG state updates
    if let Some(server) = server {
        if rng_state.dirty {
            // Send RNG state to all clients
            for client_id in server.client_player_map.keys() {
                server_res.send_message(
                    *client_id,
                    ServerChannel::Reliable,
                    bincode::serialize(&RngStateMessage {
                        state: rng_state.global_state.clone(),
                        timestamp: rng_state.last_sync,
                    }).unwrap(),
                );
            }
        }
    }
}

/// Message for RNG state synchronization
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct RngStateMessage {
    /// Serialized RNG state
    pub state: Vec<u8>,
    /// Timestamp of the state
    pub timestamp: f32,
}
}

Player-Specific RNG Components

Each player should have their own RNG component that is deterministically seeded from the global source:

#![allow(unused)]
fn main() {
// src/player/components/player_rng.rs
use bevy::prelude::*;
use bevy_prng::WyRand;
use bevy_rand::prelude::*;

/// Component for player-specific randomization
#[derive(Component)]
pub struct PlayerRng {
    /// The player's RNG component
    pub rng: Entropy<WyRand>,
    /// Whether this RNG is remotely controlled
    pub is_remote: bool,
}

/// System to initialize player RNGs
pub fn setup_player_rngs(
    mut commands: Commands,
    players: Query<(Entity, &Player), Without<PlayerRng>>,
    mut global_rng: GlobalEntropy<WyRand>,
    server: Option<Res<GameServer>>,
) {
    for (entity, player) in players.iter() {
        // On the server, create a new RNG for each player
        let is_remote = server.is_none() || !server.unwrap().is_server_player(entity);
        
        // Fork from the global RNG to maintain determinism
        commands.entity(entity).insert(PlayerRng {
            rng: global_rng.fork_rng(),
            is_remote,
        });
    }
}
}

Client-Side RNG Management

Clients need to apply RNG state updates from the server:

#![allow(unused)]
fn main() {
// src/networking/client/systems.rs
use bevy::prelude::*;
use bevy_prng::WyRand;
use bevy_rand::prelude::*;
use crate::networking::rng::RngStateMessage;

/// System to handle RNG state updates from server
pub fn handle_rng_state_update(
    mut client_rng: GlobalEntropy<WyRand>,
    mut incoming_messages: EventReader<NetworkMessage>,
) {
    for message in incoming_messages.read() {
        if let Ok(rng_message) = bincode::deserialize::<RngStateMessage>(&message.data) {
            // Apply the server's RNG state
            client_rng.deserialize_state(&rng_message.state).expect("Failed to deserialize RNG state");
            
            // Now client and server have synchronized RNG state
        }
    }
}
}

Deterministic Usage in Game Actions

To ensure deterministic behavior, game actions must use RNG components in a consistent way:

#![allow(unused)]
fn main() {
// src/game_engine/actions/dice_roll.rs
use bevy::prelude::*;
use crate::player::components::PlayerRng;
use rand::Rng;

/// System to handle dice roll actions
pub fn handle_dice_roll(
    mut commands: Commands,
    mut dice_roll_events: EventReader<DiceRollEvent>,
    mut players: Query<(&mut PlayerRng, &Player)>,
) {
    for event in dice_roll_events.read() {
        if let Ok((mut player_rng, player)) = players.get_mut(event.player_entity) {
            // Use the player's RNG to get a deterministic result
            let roll_result = player_rng.rng.gen_range(1..=event.sides);
            
            // Create the effect based on the roll result
            let effect_entity = commands.spawn(DiceRollEffect {
                player: event.player_entity,
                result: roll_result,
                sides: event.sides,
            }).id();
            
            // The effect is determined by the RNG, ensuring all clients get the same result
            // as long as they have the same RNG state and process events in the same order
        }
    }
}
}

Ensuring Consistency

To maintain RNG consistency across clients:

  1. The server is the authoritative source of RNG state
  2. All random operations use player-specific RNGs or the global RNG, never thread_rng() or other non-deterministic sources
  3. RNG state is synchronized periodically
  4. Game actions that use randomness include a sequence ID to ensure they're processed in the same order on all clients
  5. When a new player joins, they receive the current global RNG state as part of initialization

Advanced: Network Partitioning

For scenarios where different subsets of players may need different random sequences (like shuffling a deck that only certain players should know the order of):

#![allow(unused)]
fn main() {
// src/game_engine/zones/library.rs
use bevy::prelude::*;
use crate::player::components::PlayerRng;

/// System to shuffle a player's library
pub fn shuffle_library(
    mut libraries: Query<(&mut Library, &Owner)>,
    mut players: Query<&mut PlayerRng>,
    shuffle_events: EventReader<ShuffleLibraryEvent>,
) {
    for event in shuffle_events.read() {
        if let Ok((mut library, owner)) = libraries.get_mut(event.library_entity) {
            // Get the owner's RNG
            if let Ok(mut player_rng) = players.get_mut(owner.entity) {
                // Use the player's RNG to shuffle the library deterministically
                library.shuffle_with_rng(&mut player_rng.rng);
                
                // This ensures that all clients who have access to this player's 
                // library will see the same shuffle result
            }
        }
    }
}
}

By following these patterns, your MTG Commander game will maintain consistent random results across all networked clients, ensuring fair gameplay regardless of network conditions.

Multiplayer Commander Networking

This document focuses on the specific networking considerations for supporting multiplayer Commander games with 4+ players. Commander games have unique requirements that affect networking design and implementation.

Table of Contents

  1. Multiplayer Commander Format
  2. Scaling Considerations
  3. Player Interactions
  4. Politics System
  5. Zone Visibility
  6. Event Broadcasting
  7. State Management

Multiplayer Commander Format

Commander is a multiplayer-focused format with specific rules that affect networking:

  • Games typically involve 3-6 players (with 4 being the standard)
  • Each player has a designated legendary creature as their commander
  • Players start at 40 life instead of 20
  • Commander damage tracking (21+ damage from a single commander eliminates a player)
  • Free-for-all political gameplay with temporary alliances
  • Complex board states with many permanents
  • Games can last multiple hours

These characteristics require special networking considerations beyond those of typical 1v1 Magic games.

Scaling Considerations

Player Count Scaling

#![allow(unused)]
fn main() {
/// Configuration for multiplayer Commander games
#[derive(Resource)]
pub struct CommanderMultiplayerConfig {
    /// Minimum number of players required to start
    pub min_players: usize,
    /// Maximum number of players allowed
    pub max_players: usize,
    /// Whether to allow spectators
    pub allow_spectators: bool,
    /// Maximum number of spectators
    pub max_spectators: Option<usize>,
    /// Timeout for player actions (in seconds)
    pub player_timeout: Option<u32>,
    /// Whether to enable turn timer
    pub use_turn_timer: bool,
    /// Turn time limit (in seconds)
    pub turn_time_limit: Option<u32>,
}

impl Default for CommanderMultiplayerConfig {
    fn default() -> Self {
        Self {
            min_players: 2, // Support even 1v1 Commander
            max_players: 6, // Default to classic pod size
            allow_spectators: true,
            max_spectators: Some(10),
            player_timeout: Some(60), // 1 minute default timeout
            use_turn_timer: false,
            turn_time_limit: Some(120), // 2 minutes per turn
        }
    }
}
}

Data Volume Optimization

With 4+ players, the amount of state data increases significantly. Optimizing data transmission is crucial:

#![allow(unused)]
fn main() {
/// System to optimize network traffic based on player count
pub fn optimize_data_transmission(
    player_count: Res<PlayerCount>,
    mut server_config: ResMut<ServerReplicationConfig>,
) {
    // Adjust replication frequency based on player count
    match player_count.active {
        1..=2 => {
            // For 1-2 players, use higher frequency updates
            server_config.replication_frequency = 30; // ~30Hz
        }
        3..=4 => {
            // For 3-4 players, use moderate frequency
            server_config.replication_frequency = 20; // ~20Hz
        }
        _ => {
            // For 5+ players, reduce update frequency
            server_config.replication_frequency = 10; // ~10Hz
        }
    }
    
    // Adjust component replication priorities
    if player_count.active > 4 {
        // Prioritize critical components in high-player-count games
        server_config.priority_components = vec![
            ComponentPriority { type_id: TypeId::of::<Player>(), priority: 10 },
            ComponentPriority { type_id: TypeId::of::<Phase>(), priority: 9 },
            ComponentPriority { type_id: TypeId::of::<Commander>(), priority: 8 },
            // Less critical components get lower priority
            ComponentPriority { type_id: TypeId::of::<CardArt>(), priority: 1 },
        ];
    }
}
}

Player Interactions

Commander games feature unique player-to-player interactions that must be properly networked:

Table Politics

#![allow(unused)]
fn main() {
#[derive(Event, Serialize, Deserialize, Clone, Debug)]
pub struct PoliticalDealProposal {
    /// Player proposing the deal
    pub proposer: Entity,
    /// Player receiving the proposal
    pub target: Entity,
    /// The proposed deal terms
    pub terms: Vec<DealTerm>,
    /// How long the deal should last
    pub duration: DealDuration,
}

#[derive(Serialize, Deserialize, Clone, Debug)]
pub enum DealTerm {
    /// Promise not to attack
    NonAggression,
    /// Promise to attack another player
    AttackPlayer(Entity),
    /// Promise not to counter spells
    NonCountering,
    /// Promise to help with a specific threat
    RemoveThreat(Entity),
    /// One-time favor (free attack, no blocks, etc.)
    OneTimeFavor(FavorType),
    /// Custom text agreement
    Custom(String),
}

/// System to handle political deal proposals and responses
pub fn handle_political_deals(
    mut server: ResMut<RepliconServer>,
    mut proposals: EventReader<FromClient<PoliticalDealProposal>>,
    mut responses: EventReader<FromClient<PoliticalDealResponse>>,
    mut active_deals: ResMut<ActiveDeals>,
    connected_clients: Res<ConnectedClients>,
) {
    // Process new deal proposals
    for FromClient { client_id, event } in proposals.read() {
        // Validate the proposal
        if validate_deal_proposal(&event, &connected_clients) {
            // Forward the proposal to the target player
            if let Some(target_client_id) = get_client_id_for_player(event.target) {
                server.send(
                    target_client_id, 
                    RepliconChannel::UnreliableOrdered,
                    bincode::serialize(&event).unwrap()
                );
            }
        }
    }
    
    // Process deal responses
    for FromClient { client_id, event } in responses.read() {
        if event.accepted {
            // Add to active deals if accepted
            active_deals.add(event.proposal_id.clone(), event.proposal.clone());
            
            // Notify both parties
            let notification = DealAcceptedNotification {
                deal_id: event.proposal_id.clone(),
            };
            
            notify_deal_participants(&mut server, &event.proposal, &notification);
        } else {
            // Notify of rejection
            let notification = DealRejectedNotification {
                deal_id: event.proposal_id.clone(),
            };
            
            notify_deal_participants(&mut server, &event.proposal, &notification);
        }
    }
}
}

Voting Mechanism

Commander often involves voting effects from cards:

#![allow(unused)]
fn main() {
#[derive(Event, Serialize, Deserialize, Clone, Debug)]
pub struct VoteProposal {
    /// The card or effect that initiated the vote
    pub source: Entity,
    /// The player who controls the voting effect
    pub controller: Entity,
    /// The available choices
    pub choices: Vec<VoteChoice>,
    /// Time limit for voting (in seconds)
    pub time_limit: Option<u32>,
}

#[derive(Event, Serialize, Deserialize, Clone, Debug)]
pub struct PlayerVote {
    /// The vote proposal ID
    pub proposal_id: Uuid,
    /// The player casting the vote
    pub player: Entity,
    /// The chosen option
    pub choice: usize,
}

/// System to synchronize voting across all players
pub fn handle_voting(
    mut server: ResMut<RepliconServer>,
    mut vote_proposals: EventReader<FromClient<VoteProposal>>,
    mut player_votes: EventReader<FromClient<PlayerVote>>,
    mut active_votes: ResMut<ActiveVotes>,
    connected_clients: Res<ConnectedClients>,
) {
    // Process new vote proposals from effects
    for FromClient { client_id, event } in vote_proposals.read() {
        // Validate that the client is authorized to initiate this vote
        if validate_vote_proposal(&event, client_id, &connected_clients) {
            // Create and store the vote
            let vote_id = Uuid::new_v4();
            active_votes.add(vote_id, event.clone());
            
            // Broadcast to all eligible voters
            for player_entity in get_eligible_voters(&event) {
                if let Some(player_client_id) = get_client_id_for_player(player_entity) {
                    let vote_notification = VoteStartedNotification {
                        vote_id,
                        proposal: event.clone(),
                    };
                    
                    server.send(
                        player_client_id,
                        RepliconChannel::ReliableOrdered,
                        bincode::serialize(&vote_notification).unwrap()
                    );
                }
            }
        }
    }
    
    // Process incoming votes
    for FromClient { client_id, event } in player_votes.read() {
        if let Some(vote) = active_votes.get_mut(&event.proposal_id) {
            // Record the vote
            vote.record_vote(event.player, event.choice);
            
            // Check if voting is complete
            if vote.is_complete() {
                // Determine the result
                let result = vote.tally_result();
                
                // Broadcast the result to all participants
                let result_notification = VoteCompletedNotification {
                    vote_id: event.proposal_id,
                    result,
                };
                
                for participant in vote.participants() {
                    if let Some(participant_client_id) = get_client_id_for_player(participant) {
                        server.send(
                            participant_client_id,
                            RepliconChannel::ReliableOrdered,
                            bincode::serialize(&result_notification).unwrap()
                        );
                    }
                }
            }
        }
    }
}
}

Politics System

Commander games feature political elements that affect gameplay:

#![allow(unused)]
fn main() {
/// Architecture for politics system in Commander
pub struct PoliticsPlugin;

impl Plugin for PoliticsPlugin {
    fn build(&self, app: &mut App) {
        app
            // Resources
            .init_resource::<ActiveDeals>()
            .init_resource::<ActiveVotes>()
            .init_resource::<Alliances>()
            .init_resource::<CombatRestrictions>()
            .init_resource::<MonarchState>()
            
            // Components
            .register_type::<PoliticalDeal>()
            .register_type::<Alliance>()
            .register_type::<VoteWeight>()
            .register_type::<Monarch>()
            .register_type::<Goad>()
            .register_type::<CombatRestriction>()
            
            // Events
            .add_event::<PoliticalDealProposal>()
            .add_event::<PoliticalDealResponse>()
            .add_event::<VoteProposal>()
            .add_event::<PlayerVote>()
            .add_event::<MonarchChangeEvent>()
            .add_event::<GoadEvent>()
            
            // Systems
            .add_systems(Update, (
                handle_political_deals,
                handle_voting,
                track_monarch,
                enforce_goad,
                update_combat_restrictions,
                check_deal_expirations,
            ))
            
            // Replicate components and events
            .replicate::<PoliticalDeal>()
            .replicate::<Alliance>()
            .replicate::<VoteWeight>()
            .replicate::<Monarch>()
            .replicate::<Goad>()
            .replicate_event::<MonarchChangeEvent>()
            .replicate_event::<VoteCompletedNotification>()
            .replicate_event::<DealAcceptedNotification>()
            .replicate_event::<DealRejectedNotification>();
    }
}

/// System to enforce goad effects, which are political combat-forcing effects
pub fn enforce_goad(
    mut commands: Commands,
    goaded_creatures: Query<(Entity, &Goad)>,
    mut attack_restrictions: ResMut<CombatRestrictions>,
    time: Res<Time>,
) {
    // Process all goaded creatures
    for (entity, goad) in &goaded_creatures {
        // Check if the goad effect has expired
        if let Some(expiration) = goad.expires_at {
            if time.elapsed_seconds() > expiration {
                // Remove expired goad effect
                commands.entity(entity).remove::<Goad>();
                attack_restrictions.remove_restriction(entity, RestrictionType::MustAttack);
            } else {
                // Ensure the creature has attack restrictions
                if !attack_restrictions.has_restriction(entity, RestrictionType::MustAttack) {
                    attack_restrictions.add_restriction(
                        entity,
                        RestrictionType::MustAttack(goad.goaded_by),
                    );
                }
            }
        }
    }
}
}

Zone Visibility

Commander requires special handling for zone visibility:

#![allow(unused)]
fn main() {
/// System to manage zone visibility in multiplayer Commander
pub fn manage_zone_visibility(
    mut commands: Commands,
    cards: Query<(Entity, &Card, &Zone, Option<&ClientVisibility>)>,
    players: Query<(Entity, &ReplicatedClient)>,
    reveal_events: EventReader<CardRevealEvent>,
) {
    // Special case: Command Zone
    // Command zone is public, so commanders are visible to all players
    for (entity, card, zone, _) in cards.iter().filter(|(_, _, z, _)| z.zone_type == ZoneType::Command) {
        // Ensure commanders are visible to all
        commands.entity(entity).remove::<ClientVisibility>();
    }
    
    // Handle hidden zones (hand, library)
    for (entity, card, zone, existing_visibility) in cards.iter().filter(|(_, _, z, _)| 
        z.zone_type == ZoneType::Hand || z.zone_type == ZoneType::Library
    ) {
        match zone.zone_type {
            ZoneType::Hand => {
                // Only the owner can see cards in hand
                if let Some(owner_client_id) = get_client_id_for_player(card.owner, &players) {
                    let blacklist: Vec<ClientId> = players
                        .iter()
                        .filter_map(|(_, client)| {
                            if client.client_id != owner_client_id {
                                Some(client.client_id)
                            } else {
                                None
                            }
                        })
                        .collect();
                    
                    commands.entity(entity).insert(ClientVisibility {
                        policy: VisibilityPolicy::Blacklist,
                        client_ids: blacklist,
                    });
                }
            },
            ZoneType::Library => {
                // Libraries are hidden from all players
                let all_clients: Vec<ClientId> = players
                    .iter()
                    .map(|(_, client)| client.client_id)
                    .collect();
                
                commands.entity(entity).insert(ClientVisibility {
                    policy: VisibilityPolicy::Blacklist,
                    client_ids: all_clients,
                });
            },
            _ => {},
        }
    }
    
    // Process reveal events (when cards are revealed to specific players)
    for reveal_event in reveal_events.read() {
        if let Ok((entity, card, zone, _)) = cards.get(reveal_event.card) {
            // Create a whitelist of players who can see this revealed card
            let whitelist: Vec<ClientId> = reveal_event.visible_to
                .iter()
                .filter_map(|player| get_client_id_for_player(*player, &players))
                .collect();
            
            if !whitelist.is_empty() {
                commands.entity(entity).insert(ClientVisibility {
                    policy: VisibilityPolicy::Whitelist,
                    client_ids: whitelist,
                });
                
                // Schedule automatic un-reveal after the specified duration
                if let Some(duration) = reveal_event.duration {
                    commands.entity(entity).insert(TemporaryReveal {
                        revert_at: time.elapsed_seconds() + duration,
                        original_zone: zone.zone_type,
                    });
                }
            }
        }
    }
}
}

Event Broadcasting

Commander games generate many events that need to be broadcast to players:

#![allow(unused)]
fn main() {
/// System to efficiently broadcast game events to players
pub fn broadcast_game_events(
    mut server: ResMut<RepliconServer>,
    mut turn_events: EventReader<TurnEvent>,
    mut combat_events: EventReader<CombatEvent>,
    mut commander_events: EventReader<CommanderEvent>,
    mut state_events: EventReader<GameStateEvent>,
    connected_clients: Res<ConnectedClients>,
) {
    // Create a batch of events to send to each client
    let mut client_event_batches: HashMap<ClientId, Vec<GameEvent>> = HashMap::new();
    
    // Process turn events (turn start, phase changes, etc.)
    for event in turn_events.read() {
        // Turn events are public and sent to all players
        for client_id in connected_clients.iter() {
            client_event_batches
                .entry(*client_id)
                .or_default()
                .push(GameEvent::Turn(event.clone()));
        }
    }
    
    // Process combat events
    for event in combat_events.read() {
        // Combat events are public and sent to all players
        for client_id in connected_clients.iter() {
            client_event_batches
                .entry(*client_id)
                .or_default()
                .push(GameEvent::Combat(event.clone()));
        }
    }
    
    // Process commander-specific events
    for event in commander_events.read() {
        match event {
            CommanderEvent::CommanderCast { player, commander } => {
                // Public event, send to all
                for client_id in connected_clients.iter() {
                    client_event_batches
                        .entry(*client_id)
                        .or_default()
                        .push(GameEvent::Commander(event.clone()));
                }
            },
            CommanderEvent::CommanderDamage { source, target, amount } => {
                // Public event, send to all
                for client_id in connected_clients.iter() {
                    client_event_batches
                        .entry(*client_id)
                        .or_default()
                        .push(GameEvent::Commander(event.clone()));
                }
            },
            CommanderEvent::PlayerEliminated { player, reason } => {
                // Public event, send to all
                for client_id in connected_clients.iter() {
                    client_event_batches
                        .entry(*client_id)
                        .or_default()
                        .push(GameEvent::Commander(event.clone()));
                }
            },
        }
    }
    
    // Process game state events
    for event in state_events.read() {
        // Game state events might contain hidden information
        // Filter based on event type and player visibility
        match event {
            GameStateEvent::CardDrawn { player, card } => {
                // Only notify the player who drew the card
                if let Some(client_id) = get_client_id_for_player(*player) {
                    client_event_batches
                        .entry(client_id)
                        .or_default()
                        .push(GameEvent::State(event.clone()));
                }
                
                // Notify others of a card drawn but hide the card identity
                let public_event = GameStateEvent::CardDrawn {
                    player: *player,
                    card: Entity::PLACEHOLDER, // Hide the actual card
                };
                
                for client_id in connected_clients.iter() {
                    if Some(*client_id) != get_client_id_for_player(*player) {
                        client_event_batches
                            .entry(*client_id)
                            .or_default()
                            .push(GameEvent::State(public_event.clone()));
                    }
                }
            },
            GameStateEvent::LibrarySearched { player, cards_revealed } => {
                // Only the searching player sees the cards
                if let Some(client_id) = get_client_id_for_player(*player) {
                    client_event_batches
                        .entry(client_id)
                        .or_default()
                        .push(GameEvent::State(event.clone()));
                }
                
                // Others just know a search happened
                let public_event = GameStateEvent::LibrarySearched {
                    player: *player,
                    cards_revealed: Vec::new(), // Hide the actual cards
                };
                
                for client_id in connected_clients.iter() {
                    if Some(*client_id) != get_client_id_for_player(*player) {
                        client_event_batches
                            .entry(*client_id)
                            .or_default()
                            .push(GameEvent::State(public_event.clone()));
                    }
                }
            },
            // Public events are sent to everyone
            GameStateEvent::LifeTotalChanged { player, new_total, change } |
            GameStateEvent::ManaAdded { player, mana } |
            GameStateEvent::PermanentEntered { permanent, controller } => {
                for client_id in connected_clients.iter() {
                    client_event_batches
                        .entry(*client_id)
                        .or_default()
                        .push(GameEvent::State(event.clone()));
                }
            },
        }
    }
    
    // Send batched events to clients
    for (client_id, events) in client_event_batches {
        if !events.is_empty() {
            let event_batch = EventBatch {
                events,
                timestamp: time.elapsed_seconds(),
            };
            
            server.send(
                client_id,
                RepliconChannel::ReliableOrdered,
                bincode::serialize(&event_batch).unwrap(),
            );
        }
    }
}
}

State Management

For large Commander games, state management needs special attention:

#![allow(unused)]
fn main() {
/// Enhanced state sync system for multiplayer Commander
pub fn sync_commander_game_state(
    mut commands: Commands,
    mut server: ResMut<RepliconServer>,
    game_state: Res<GameState>,
    turn_manager: Res<TurnManager>,
    zone_manager: Res<ZoneManager>,
    player_query: Query<(Entity, &Player, &CommanderState)>,
    connected_clients: Res<ConnectedClients>,
    mut client_sync_status: ResMut<ClientSyncStatus>,
    time: Res<Time>,
) {
    // Determine which clients need full or delta syncs
    for client_id in connected_clients.iter() {
        let client_status = client_sync_status.entry(*client_id).or_insert(SyncStatus {
            last_full_sync: 0.0,
            last_delta_sync: 0.0,
            sync_generation: 0,
        });
        
        let current_time = time.elapsed_seconds();
        
        // Client needs a full sync if:
        // 1. Never received a full sync before
        // 2. Haven't received a full sync in a long time
        // 3. Just connected
        let needs_full_sync = client_status.last_full_sync == 0.0 || 
                             current_time - client_status.last_full_sync > 30.0 ||
                             client_status.sync_generation == 0;
        
        // Regular delta sync interval
        let needs_delta_sync = current_time - client_status.last_delta_sync > 0.25; // 4 Hz
        
        if needs_full_sync {
            // Send full game state to client
            send_full_game_state(&mut server, *client_id, &game_state, &turn_manager, &zone_manager, &player_query);
            
            // Update sync status
            client_status.last_full_sync = current_time;
            client_status.last_delta_sync = current_time;
            client_status.sync_generation += 1;
        } else if needs_delta_sync {
            // Send delta update
            send_delta_update(&mut server, *client_id, &game_state, &turn_manager, &player_query);
            
            // Update sync status
            client_status.last_delta_sync = current_time;
        }
    }
}

/// Optimized serialization with compression for large game states
fn send_full_game_state(
    server: &mut ResMut<RepliconServer>,
    client_id: ClientId,
    game_state: &Res<GameState>,
    turn_manager: &Res<TurnManager>,
    zone_manager: &Res<ZoneManager>,
    player_query: &Query<(Entity, &Player, &CommanderState)>,
) {
    // Create a full game state snapshot
    let mut snapshot = GameStateSnapshot {
        is_full_sync: true,
        sync_generation: time.frame_count(),
        turn: turn_manager.turn_number,
        phase: turn_manager.current_phase.clone(),
        active_player: turn_manager.active_player,
        priority_player: turn_manager.priority_player,
        players: Vec::new(),
        zones: Vec::new(),
        permanents: Vec::new(),
        stack: Vec::new(),
    };
    
    // Add player data (filtering based on client visibility)
    for (entity, player, commander_state) in player_query.iter() {
        snapshot.players.push(PlayerSnapshot {
            entity,
            name: player.name.clone(),
            life: player.life,
            mana_pool: player.mana_pool.clone(),
            commander: commander_state.commander,
            commander_casts: commander_state.cast_count,
            commander_damage_received: commander_state.commander_damage_received.clone(),
        });
    }
    
    // Add zone data
    for (zone_entity, zone) in zone_manager.zones.iter() {
        // Filter cards based on visibility to this client
        let visible_cards = filter_visible_cards(zone, client_id);
        
        snapshot.zones.push(ZoneSnapshot {
            entity: *zone_entity,
            zone_type: zone.zone_type,
            owner: zone.owner,
            cards: visible_cards,
        });
    }
    
    // Add permanents on battlefield
    for permanent in game_state.battlefield.iter() {
        if let Ok(permanent_data) = get_permanent_data(*permanent) {
            snapshot.permanents.push(permanent_data);
        }
    }
    
    // Add stack items
    for stack_item in game_state.stack.items() {
        snapshot.stack.push(StackItemSnapshot {
            entity: stack_item.entity,
            source: stack_item.source,
            controller: stack_item.controller,
            targets: stack_item.targets.clone(),
        });
    }
    
    // Compress the snapshot to reduce bandwidth usage
    let serialized = bincode::serialize(&snapshot).unwrap();
    let compressed = compress_data(&serialized);
    
    // Send the compressed snapshot
    server.send(
        client_id,
        RepliconChannel::ReliableOrdered,
        Bytes::from(compressed),
    );
}
}

This document provides detailed guidance on the specific networking considerations for multiplayer Commander games, focusing on scaling challenges, political elements, and the complexity of managing large game states across many players.

Networking Protocol Specification

This document defines the protocol used for networking in our MTG Commander game engine. It outlines the message formats, synchronization mechanisms, and handling of game-specific concepts.

Table of Contents

  1. Message Format
  2. Connection Handshake
  3. Authentication
  4. Game State Synchronization
  5. Player Actions
  6. Card Interactions
  7. MTG-Specific Handling
  8. Error Handling

Message Format

All networked messages follow a standardized format using bevy_replicon's structure:

#![allow(unused)]
fn main() {
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct NetworkMessage<T> {
    /// Message type identifier
    pub message_type: MessageType,
    /// Payload data
    pub payload: T,
    /// Sequence number for ordering
    pub sequence: u64,
    /// Timestamp when the message was created
    pub timestamp: f64,
}

#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
pub enum MessageType {
    /// Game state synchronization
    GameState,
    /// Player action
    Action,
    /// System message
    System,
    /// Chat message
    Chat,
    /// Error message
    Error,
}
}

Connection Handshake

The connection handshake establishes a client-server relationship and verifies compatibility:

  1. Client Request: Client sends connection request with version information
  2. Server Validation: Server validates compatibility and available slots
  3. Connection Accept/Reject: Server sends acceptance or rejection
  4. Game State Sync: Server sends initial game state if accepted
#![allow(unused)]
fn main() {
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct ConnectionRequest {
    /// Client version
    pub version: String,
    /// Player name/identifier
    pub player_name: String,
    /// Unique client identifier
    pub client_id: Option<u64>,
    /// Reconnection token for game in progress (if any)
    pub reconnect_token: Option<String>,
}

#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct ConnectionResponse {
    /// Whether the connection was accepted
    pub accepted: bool,
    /// Reason for rejection if applicable
    pub rejection_reason: Option<String>,
    /// Server-assigned client ID
    pub assigned_client_id: Option<u64>,
    /// Session identifier
    pub session_id: Option<String>,
}
}

Authentication

For secure multiplayer, we implement a simple authentication system:

#![allow(unused)]
fn main() {
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct AuthRequest {
    /// Username or identifier
    pub username: String,
    /// Password or token
    pub password_hash: String,
}

#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct AuthResponse {
    /// Whether authentication was successful
    pub success: bool,
    /// Authentication token for future use
    pub token: Option<String>,
    /// Expiration time for the token
    pub expiration: Option<f64>,
}
}

Game State Synchronization

Game state synchronization happens at multiple levels:

  1. Full State Sync: Complete game state sent on connection or major changes
  2. Delta Updates: Only changed components sent during normal gameplay
  3. Targeted Updates: Some updates only sent to specific clients (for hidden information)
#![allow(unused)]
fn main() {
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct GameStateSync {
    /// Whether this is a full sync or delta
    pub is_full_sync: bool,
    /// Current game turn
    pub turn: u32,
    /// Current game phase
    pub phase: Phase,
    /// Active player entity ID
    pub active_player: u64,
    /// Priority player entity ID
    pub priority_player: Option<u64>,
    /// Entity changes (components)
    pub entity_changes: Vec<EntityChange>,
    /// Game events that occurred
    pub events: Vec<GameEvent>,
}

#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct EntityChange {
    /// Entity ID
    pub entity_id: u64,
    /// Component changes
    pub components: Vec<ComponentChange>,
    /// Whether the entity was created
    pub is_new: bool,
    /// Whether the entity was destroyed
    pub is_removed: bool,
}
}

Player Actions

Players send action requests to the server, which validates and processes them:

#![allow(unused)]
fn main() {
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct PlayerActionRequest {
    /// Client ID
    pub client_id: u64,
    /// Action type
    pub action_type: NetworkedActionType,
    /// Target entities
    pub targets: Vec<u64>,
    /// Action parameters
    pub parameters: Option<ActionParameters>,
}

#[derive(Serialize, Deserialize, Clone, Debug)]
pub enum NetworkedActionType {
    /// Play a land card
    PlayLand,
    /// Cast a spell
    CastSpell,
    /// Activate an ability
    ActivateAbility,
    /// Declare attackers
    DeclareAttackers,
    /// Declare blockers
    DeclareBlockers,
    /// Pass priority
    PassPriority,
    /// Make a mulligan decision
    Mulligan,
    /// Choose to put a commander in the command zone
    CommanderZoneChoice,
    /// Stack response
    RespondToStack,
    /// Choose targets
    ChooseTargets,
}

#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct ActionParameters {
    /// Mana payment information
    pub mana_payment: Option<Mana>,
    /// Ability index (for cards with multiple abilities)
    pub ability_index: Option<usize>,
    /// X value for spells with X in cost
    pub x_value: Option<u32>,
    /// Selected mode for modal spells
    pub selected_mode: Option<u32>,
    /// Additional costs paid
    pub additional_costs: Option<Vec<AdditionalCost>>,
}
}

Card Interactions

Card interactions require special handling for targeting, stack effects, and zone changes:

#![allow(unused)]
fn main() {
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct TargetSelection {
    /// Source card/ability
    pub source: u64,
    /// Selected targets
    pub targets: Vec<Target>,
    /// Whether all required targets have been selected
    pub is_complete: bool,
}

#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct Target {
    /// Target entity ID
    pub entity_id: u64,
    /// Target type
    pub target_type: TargetType,
}

#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
pub enum TargetType {
    /// Player
    Player,
    /// Creature
    Creature,
    /// Permanent
    Permanent,
    /// Spell on stack
    Spell,
    /// Zone
    Zone,
}

#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct StackEffect {
    /// Effect ID
    pub id: u64,
    /// Source entity
    pub source: u64,
    /// Controller
    pub controller: u64,
    /// Targets
    pub targets: Vec<Target>,
    /// Effect details
    pub effect: EffectDetails,
}
}

MTG-Specific Handling

MTG has unique concepts that require special handling:

Hidden Information

#![allow(unused)]
fn main() {
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct HiddenInformation {
    /// Zone type containing hidden information
    pub zone: ZoneType,
    /// Owner of the zone
    pub owner: u64,
    /// Hidden card IDs
    pub card_ids: Vec<u64>,
    /// Whether zone contents were reordered
    pub reordered: bool,
}

#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct RevealedCard {
    /// Card entity ID
    pub card_id: u64,
    /// Zone before reveal
    pub from_zone: ZoneType,
    /// Players who can see the card
    pub visible_to: Vec<u64>,
    /// Reveal source (card/effect that caused reveal)
    pub reveal_source: Option<u64>,
}
}

Randomization

To prevent cheating, all randomization happens on the server:

#![allow(unused)]
fn main() {
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct RandomizationRequest {
    /// Type of randomization
    pub randomization_type: RandomizationType,
    /// Entities involved
    pub entities: Vec<u64>,
    /// Additional parameters
    pub parameters: Option<RandomizationParameters>,
}

#[derive(Serialize, Deserialize, Clone, Debug)]
pub enum RandomizationType {
    /// Shuffle a library
    ShuffleLibrary,
    /// Flip a coin
    CoinFlip,
    /// Roll a die
    DieRoll,
    /// Select random targets
    RandomTargets,
    /// Random card discard
    RandomDiscard,
}
}

Priority System

#![allow(unused)]
fn main() {
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct PriorityUpdate {
    /// Player with priority
    pub priority_player: u64,
    /// Time left to respond (in milliseconds)
    pub time_remaining: Option<u64>,
    /// What's currently on the stack
    pub stack_size: usize,
    /// Current phase/step
    pub current_phase: Phase,
}
}

Error Handling

#![allow(unused)]
fn main() {
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct NetworkError {
    /// Error code
    pub code: u32,
    /// Error message
    pub message: String,
    /// Related entity (if applicable)
    pub related_entity: Option<u64>,
    /// Related action (if applicable)
    pub related_action: Option<NetworkedActionType>,
}

#[derive(Serialize, Deserialize, Clone, Debug)]
pub enum ErrorCode {
    /// Invalid action
    InvalidAction = 1000,
    /// Invalid target
    InvalidTarget = 1001,
    /// Not your turn
    NotYourTurn = 1002,
    /// Not your priority
    NotYourPriority = 1003,
    /// Invalid mana payment
    InvalidManaPayment = 1004,
    /// Game rule violation
    GameRuleViolation = 1005,
    /// Connection error
    ConnectionError = 2000,
    /// Synchronization error
    SyncError = 2001,
    /// Internal server error
    InternalError = 9000,
}
}

This protocol specification provides a comprehensive framework for networked gameplay in our MTG Commander engine. It balances efficiency, security, and game rule enforcement while handling the unique requirements of Magic: The Gathering, such as hidden information, complex targeting, and state-based effects.

Bevy Replicon Integration

This guide explains how Rummage integrates with bevy_replicon to provide networked multiplayer functionality.

Table of Contents

  1. Introduction to Replicon
  2. Replicon Architecture
  3. Replication Setup
  4. Replicated Components
  5. Server Authority
  6. Client Prediction
  7. Deterministic RNG
  8. Network Events
  9. Optimizing Network Traffic
  10. Testing Networked Gameplay
  11. Troubleshooting

Introduction to Replicon

Bevy Replicon is a networking library for Bevy that provides entity and component replication between server and clients. Rummage uses Replicon to enable multiplayer Magic: The Gathering games over a network.

Replicon follows a client-server model where:

  • The server has authority over game state
  • Clients receive updates from the server
  • Client inputs are sent to the server
  • The server processes inputs and updates the game state

Replicon Architecture

The networking architecture in Rummage builds on Replicon's core features:

Core Concepts

  • Replication: The process of synchronizing entities and components from server to clients
  • Client Prediction: Clients predict the outcome of their actions while waiting for server confirmation
  • Server Authority: The server is the ultimate authority on game state
  • Rollback: The ability to roll back and reapply actions when prediction is incorrect

Plugin Integration

Rummage integrates Replicon through a dedicated plugin:

#![allow(unused)]
fn main() {
pub struct RummageNetworkPlugin;

impl Plugin for RummageNetworkPlugin {
    fn build(&self, app: &mut App) {
        // Add Replicon's server and client plugins based on configuration
        app.add_plugins(RepliconPlugins)
            // Add Rummage-specific network resources
            .init_resource::<NetworkConfig>()
            .init_resource::<ClientConnectionManager>()
            
            // Register replication types
            .register_type::<CardPosition>()
            .register_type::<PlayerLife>()
            .register_type::<HandContents>()
            // ... and more component types
            
            // Add network-specific systems
            .add_systems(PreUpdate, connect_to_server.run_if(resource_exists::<ClientConfig>()))
            .add_systems(Update, (
                handle_connection_events,
                process_player_actions,
                sync_game_state,
            ));
    }
}
}

Replication Setup

Setting up replication in Rummage involves several key steps:

Server Setup

#![allow(unused)]
fn main() {
fn setup_server(mut commands: Commands, config: Res<NetworkConfig>) {
    // Create the server
    let server = RenetServer::new(ConnectionConfig {
        protocol: ProtocolId::default(),
        server_channels_config: ServerChannelsConfig::default(),
        client_channels_config: ClientChannelsConfig::default(),
        authentication: ServerAuthentication::Unsecure,
    });
    
    // Add server components
    commands.insert_resource(server);
    commands.insert_resource(IsServer(true));
    
    // Initialize server game state
    commands.insert_resource(ServerGameState::default());
    
    info!("Server started on port {}", config.server_port);
}
}

Client Setup

#![allow(unused)]
fn main() {
fn connect_to_client(mut commands: Commands, client_config: Res<ClientConfig>) {
    // Create the client
    let client = RenetClient::new(ConnectionConfig {
        protocol: ProtocolId::default(),
        server_channels_config: ServerChannelsConfig::default(),
        client_channels_config: ClientChannelsConfig::default(),
        authentication: ClientAuthentication::Unsecure,
    });
    
    // Add client components
    commands.insert_resource(client);
    commands.insert_resource(IsServer(false));
    
    info!("Client connecting to {}:{}", 
          client_config.server_address, 
          client_config.server_port);
}
}

Replicated Components

Components that need network synchronization must be marked with Replicon's Replicate component:

#![allow(unused)]
fn main() {
// Card component that will be replicated
#[derive(Component, Serialize, Deserialize, Clone, Debug, Reflect)]
#[reflect(Component)] // Required for Replicon to reflect the component
pub struct Card {
    pub id: String,
    pub name: String,
    pub card_type: CardType,
    // Other card properties...
}

// In your setup code, mark entities for replication
fn setup_replication(mut commands: Commands) {
    // Spawn an entity with components that will be replicated
    commands.spawn((
        Card { 
            id: "some_card_id",
            name: "Card Name",
            card_type: CardType::Creature,
            // ...
        },
        // Mark this component for replication
        Replicate,
    ));
}
}

Some entities may have components that should only exist on the server:

#![allow(unused)]
fn main() {
// ServerOnly component is not replicated to clients
#[derive(Component)]
pub struct ServerOnly {
    pub secret_data: String,
}

// ServerReplication marks a type as server-only
commands.spawn((
    Card { /* ... */ },
    ServerOnly { secret_data: "hidden from clients" },
    Replicate,
    ServerReplication, // This entity is only replicated from server to clients
));
}

Server Authority

The server maintains authority over game state through several mechanisms:

Authoritative Systems

#![allow(unused)]
fn main() {
// This system only runs on the server
fn process_game_actions(
    mut commands: Commands,
    mut action_events: EventReader<GameActionEvent>,
    mut game_state: ResMut<GameState>,
    is_server: Res<IsServer>,
) {
    // Only run on the server
    if !is_server.0 {
        return;
    }
    
    for action in action_events.iter() {
        // Process the action authoritatively
        match action {
            GameActionEvent::PlayCard { player_id, card_id, target } => {
                // Server implementation of playing a card
                // This will automatically be replicated to clients
            },
            // Handle other actions...
        }
    }
}
}

Action Validation

#![allow(unused)]
fn main() {
fn validate_card_play(
    action: &PlayCardAction,
    player_state: &PlayerState,
    game_state: &GameState,
) -> Result<(), ActionError> {
    // Validate the player has the card in hand
    if !player_state.hand.contains(&action.card_id) {
        return Err(ActionError::InvalidCard);
    }
    
    // Validate the player has enough mana
    let card = game_state.cards.get(&action.card_id)
        .ok_or(ActionError::CardNotFound)?;
        
    if !player_state.can_pay_mana_cost(&card.mana_cost) {
        return Err(ActionError::InsufficientMana);
    }
    
    // Additional validation logic...
    
    Ok(())
}
}

Client Prediction

To provide a responsive feel, clients can predict the outcome of actions while waiting for server confirmation:

#![allow(unused)]
fn main() {
fn client_predict_card_play(
    mut commands: Commands,
    mut action_events: EventReader<PlayCardAction>,
    mut game_state: ResMut<ClientGameState>,
    is_server: Res<IsServer>,
) {
    // Only run on clients
    if is_server.0 {
        return;
    }
    
    for action in action_events.iter() {
        // Make a prediction about what will happen
        if let Some(card) = game_state.player.hand.remove(&action.card_id) {
            // Create a predicted entity for the card on the battlefield
            commands.spawn((
                card.clone(),
                PredictedEntity, // Mark as a prediction that might need correction
                BattlefieldCard { position: action.position },
                // ...
            ));
            
            // Update predicted game state
            game_state.prediction_applied = true;
            
            // Send the action to the server for confirmation
            // ...
        }
    }
}
}

When server confirmation is received, predictions are validated or corrected:

#![allow(unused)]
fn main() {
fn handle_server_confirmation(
    mut commands: Commands,
    mut confirmation_events: EventReader<ServerConfirmationEvent>,
    mut predicted_query: Query<(Entity, &PredictedEntity)>,
    mut game_state: ResMut<ClientGameState>,
) {
    for confirmation in confirmation_events.iter() {
        if confirmation.action_id == game_state.last_prediction_id {
            if confirmation.success {
                // Remove the prediction marker as it was correct
                for (entity, _) in predicted_query.iter() {
                    commands.entity(entity).remove::<PredictedEntity>();
                }
            } else {
                // Prediction was wrong, remove predicted entities
                for (entity, _) in predicted_query.iter() {
                    commands.entity(entity).despawn();
                }
                
                // Server will send the correct state via replication
                game_state.prediction_applied = false;
            }
        }
    }
}
}

Deterministic RNG

For card games, deterministic random number generation is critical to ensure all clients and the server see the same outcome:

#![allow(unused)]
fn main() {
// Resource for synchronized RNG
#[derive(Resource)]
pub struct DeterministicRng {
    rng: StdRng,
    seed: u64,
    sequence: u64,
}

impl Default for DeterministicRng {
    fn default() -> Self {
        let seed = 12345; // In practice, use a seed from server
        Self {
            rng: StdRng::seed_from_u64(seed),
            seed,
            sequence: 0,
        }
    }
}

impl DeterministicRng {
    // Get a random value and advance the sequence
    pub fn get_random(&mut self) -> u32 {
        self.sequence += 1;
        self.rng.next_u32()
    }
    
    // Reset to a specific sequence point
    pub fn reset_to_sequence(&mut self, sequence: u64) {
        // Reseed with original seed
        self.rng = StdRng::seed_from_u64(self.seed);
        
        // Fast-forward to the specified sequence
        for _ in 0..sequence {
            self.rng.next_u32();
        }
        
        self.sequence = sequence;
    }
}
}

Synchronizing RNG state:

#![allow(unused)]
fn main() {
// Server sends RNG state to clients
fn sync_rng_state(
    mut sync_events: EventWriter<RngSyncEvent>,
    rng: Res<DeterministicRng>,
    is_server: Res<IsServer>,
) {
    if is_server.0 && rng.is_changed() {
        sync_events.send(RngSyncEvent {
            seed: rng.seed,
            sequence: rng.sequence,
        });
    }
}

// Clients update their RNG state
fn apply_rng_sync(
    mut sync_events: EventReader<RngSyncEvent>,
    mut rng: ResMut<DeterministicRng>,
    is_server: Res<IsServer>,
) {
    if !is_server.0 {
        for event in sync_events.iter() {
            rng.seed = event.seed;
            rng.reset_to_sequence(event.sequence);
        }
    }
}
}

Network Events

Rummage uses events to communicate between server and clients:

#![allow(unused)]
fn main() {
// Server to client events
#[derive(Event, Serialize, Deserialize)]
pub enum ServerToClientEvent {
    GameStateUpdate(GameStateSnapshot),
    PlayerJoined { player_id: u64, name: String },
    PlayerLeft { player_id: u64 },
    ChatMessage { player_id: u64, message: String },
    GameAction { action_id: u64, action: GameAction },
}

// Client to server events
#[derive(Event, Serialize, Deserialize)]
pub enum ClientToServerEvent {
    RequestAction { action_id: u64, action: GameAction },
    ChatMessage { message: String },
    Ready,
    Concede,
}
}

Handling these events:

#![allow(unused)]
fn main() {
fn handle_client_events(
    mut client_events: EventReader<ClientToServerEvent>,
    mut game_state: ResMut<GameState>,
    mut player_states: Query<&mut PlayerState>,
    is_server: Res<IsServer>,
) {
    if !is_server.0 {
        return;
    }
    
    for event in client_events.iter() {
        match event {
            ClientToServerEvent::RequestAction { action_id, action } => {
                // Process client action request
                // ...
            },
            ClientToServerEvent::ChatMessage { message } => {
                // Broadcast chat to all clients
                // ...
            },
            // Handle other events...
        }
    }
}
}

Optimizing Network Traffic

Efficient network usage is important for a smooth multiplayer experience:

Bandwidth Optimization

#![allow(unused)]
fn main() {
// Configure what components to replicate and how often
fn configure_replication(mut config: ResMut<ReplicationConfig>) {
    // High-priority components update every frame
    config.set_frequency::<PlayerLife>(UpdateFrequency::EveryFrame);
    
    // Medium-priority components update less frequently
    config.set_frequency::<CardPosition>(UpdateFrequency::Every(2));
    
    // Low-priority components update rarely
    config.set_frequency::<PlayerName>(UpdateFrequency::Every(60));
}
}

Delta Compression

#![allow(unused)]
fn main() {
// Only send component changes, not full state
fn optimize_card_updates(
    mut card_query: Query<&mut Card, Changed<Card>>,
    mut replicon: ResMut<RepliconServer>,
) {
    for mut card in card_query.iter_mut() {
        // Replicon will only send the changes
        // No need to do anything special here as Replicon
        // automatically detects and sends only changed components
    }
}
}

Testing Networked Gameplay

Testing multiplayer functionality is crucial:

#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
    use super::*;
    
    #[test]
    fn test_client_server_sync() {
        // Create a server app
        let mut server_app = App::new();
        server_app
            .add_plugins(MinimalPlugins)
            .add_plugin(RepliconServerPlugin)
            .add_plugin(RummageNetworkPlugin)
            .insert_resource(NetworkConfig {
                server_port: 42424,
                max_clients: 4,
            });
            
        // Create a client app
        let mut client_app = App::new();
        client_app
            .add_plugins(MinimalPlugins)
            .add_plugin(RepliconClientPlugin)
            .add_plugin(RummageNetworkPlugin)
            .insert_resource(ClientConfig {
                server_address: "127.0.0.1".to_string(),
                server_port: 42424,
            });
            
        // Simulate connection and game actions
        // ...
        
        // Verify client and server have synchronized state
        // ...
    }
}
}

Troubleshooting

Common Network Issues

Connection Failures

If clients can't connect to the server:

  1. Verify the server is running and listening on the correct port
  2. Check firewall settings
  3. Ensure the client is using the correct server address and port
  4. Check for network connectivity between the client and server

Desynchronization

If clients become desynchronized from the server:

  1. Check for non-deterministic behavior in game logic
  2. Ensure all RNG is using the deterministic RNG system
  3. Verify replicated components have proper Change detection
  4. Check for race conditions in event handling

High Latency

If game actions feel sluggish:

  1. Optimize the frequency of component replication
  2. Implement more client-side prediction
  3. Consider delta compression for large state changes
  4. Profile network traffic to identify bottlenecks

For complete examples of networked gameplay, see the Multiplayer Samples section.

Multiplayer Lobby System Documentation

This document serves as an index for all documentation related to Rummage's multiplayer lobby system for Commander format games. These documents cover the user interface, networking architecture, and implementation details for creating a robust and enjoyable multiplayer experience.

Overview and Architecture

User Interface Components

Gameplay and Departure Handling

Implementation Guides

Feature Highlights

Server List and Direct Connect

The lobby system supports two connection methods:

  1. Server List: Browse a list of available lobby servers
  2. Direct Connect: Connect directly to a specific host's IP

Lobby Browsing

The browser screen provides a comprehensive view of available lobbies with:

  • Lobby name and host information
  • Player count and maximum players
  • Game format details
  • Password protection indicator
  • Filtering and sorting options

In-Lobby Features

Once in a lobby, players can:

  • Chat with other players
  • View player information
  • Select a deck and indicate readiness
  • View other players' commander cards and deck information
  • Manage privacy settings for their decks

Host Controls

Lobby hosts have additional capabilities:

  • Kick players
  • Set lobby rules and restrictions
  • Configure game settings
  • Launch the game when all players are ready

Privacy and Deck Sharing

The system implements flexible privacy settings:

  • Share just commander information
  • Share basic deck statistics
  • Share full decklist
  • Customizable sharing options

Robust Departure Handling

The system gracefully handles various departure scenarios:

  • Voluntary quits
  • Being kicked by host
  • Temporary disconnections with reconnection window
  • Preservation of game state for departed players

Implementation Details

The lobby system is built using:

  • Bevy ECS: Component-based architecture for UI and game state
  • Bevy Replicon: Networking and state replication
  • WebRTC: For efficient and reliable UDP-based communication
  • Delta Compression: For efficient state updates

Getting Started

To get started with implementing or extending the lobby system, we recommend:

  1. Review the Lobby UI System Overview for a high-level understanding
  2. Examine the Lobby Networking document to understand the communication architecture
  3. Follow the step-by-step Lobby Implementation Guide

Future Enhancements

Planned enhancements for the lobby system include:

  • Voice chat integration
  • Advanced deck analysis tools
  • Tournament mode
  • Spectator support
  • Enhanced moderation tools

Matchmaking

Friend System

Lobby Backend System

This section covers the server-side backend implementation of the lobby system for multiplayer Commander games.

Overview

The lobby backend handles player matchmaking, game creation, and transition to gameplay. It serves as the central coordination point for players before a game begins.

Components

Implementation

Detailed implementation of the lobby backend, including:

  • Server architecture
  • Player session management
  • Game room creation and configuration
  • Player queuing and matchmaking algorithms
  • Ready state tracking
  • Game initialization

Networking

Networking aspects of the lobby backend, including:

  • Protocol details for lobby communication
  • Message formats and serialization
  • Connection management
  • Authentication and session handling
  • Error handling and recovery
  • Security considerations

Integration

The lobby backend integrates with:

Multiplayer Lobby Backend Implementation

This document focuses on the server-side implementation details of the lobby system for Rummage's multiplayer Commander games. It covers the networking architecture, server infrastructure, and data flow between clients and servers.

Table of Contents

  1. Architecture Overview
  2. Lobby Server Implementation
  3. Game Server Implementation
  4. Connection and Protocol
  5. Data Persistence
  6. Security Considerations
  7. Testing and Validation
  8. Deployment Considerations

Architecture Overview

The multiplayer system uses a dual-server architecture:

  1. Lobby Server: Manages lobbies, matchmaking, and initial connections
  2. Game Server: Handles the actual Commander gameplay after a match starts
                         ┌────────────────┐
                         │  Lobby Server  │
                         └───────┬────────┘
                                 │
                 ┌───────────────┼───────────────┐
                 │               │               │
         ┌───────┴─────┐ ┌───────┴─────┐ ┌───────┴─────┐
         │ Game Server │ │ Game Server │ │ Game Server │
         └─────────────┘ └─────────────┘ └─────────────┘

Components

  1. Lobby Manager: Tracks active lobbies and their states
  2. Session Manager: Handles player authentication and persistence
  3. Game Instance Factory: Creates and configures new game instances
  4. Message Broker: Routes communications between components
  5. Persistence Layer: Stores lobby and game data

Lobby Server Implementation

The Lobby Server is responsible for:

  1. Managing the list of available lobbies
  2. Handling player authentication
  3. Processing lobby creation, updates, and deletions
  4. Facilitating player chat and interactions in lobbies
  5. Initiating game sessions when a match starts
#![allow(unused)]
fn main() {
/// Main lobby server resource
#[derive(Resource)]
pub struct LobbyServer {
    /// Active lobbies indexed by ID
    pub lobbies: HashMap<String, LobbyData>,
    /// Connected clients
    pub clients: HashMap<ClientId, LobbyClientInfo>,
    /// Available game servers
    pub game_servers: Vec<GameServerInfo>,
}

/// Data structure for tracking a lobby
#[derive(Clone, Debug)]
pub struct LobbyData {
    /// Lobby information visible to players
    pub info: LobbyInfo,
    /// Detailed lobby settings
    pub settings: LobbySettings,
    /// Players in the lobby
    pub players: HashMap<String, LobbyPlayer>,
    /// Chat history
    pub chat_history: Vec<ChatMessage>,
    /// Creation timestamp
    pub created_at: f64,
    /// Last activity timestamp
    pub last_activity: f64,
}

/// Systems to handle lobby server operations
pub fn handle_lobby_connections(
    mut server: ResMut<RepliconServer>,
    mut lobby_server: ResMut<LobbyServer>,
    mut connection_events: EventReader<ServerEvent>,
    time: Res<Time>,
) {
    for event in connection_events.read() {
        match event {
            ServerEvent::ClientConnected { client_id } => {
                // Add client to connected clients
                lobby_server.clients.insert(*client_id, LobbyClientInfo {
                    client_id: *client_id,
                    state: LobbyClientState::Connected,
                    username: None,
                    lobby_id: None,
                    connected_at: time.elapsed_seconds(),
                    last_activity: time.elapsed_seconds(),
                });
            }
            ServerEvent::ClientDisconnected { client_id, reason } => {
                // Handle client disconnection
                if let Some(client_info) = lobby_server.clients.remove(client_id) {
                    // If client was in a lobby, remove them
                    if let Some(lobby_id) = client_info.lobby_id {
                        if let Some(lobby) = lobby_server.lobbies.get_mut(&lobby_id) {
                            // Remove player from lobby
                            if let Some(username) = &client_info.username {
                                lobby.players.remove(username);
                                
                                // Add system message about player leaving
                                lobby.chat_history.push(ChatMessage {
                                    id: generate_uuid(),
                                    sender: "System".to_string(),
                                    is_system: true,
                                    content: format!("{} has left the lobby", username),
                                    timestamp: time.elapsed_seconds(),
                                });
                                
                                // Notify other players in the lobby
                                notify_lobby_update(&mut server, &lobby_id, &lobby_server);
                                
                                // If lobby is empty or host left, handle lobby cleanup
                                handle_potential_lobby_cleanup(&mut lobby_server, &lobby_id);
                            }
                        }
                    }
                }
            }
        }
    }
}

/// Process incoming lobby actions
pub fn process_lobby_requests(
    mut server: ResMut<RepliconServer>,
    mut lobby_server: ResMut<LobbyServer>,
    mut lobby_requests: EventReader<FromClient<LobbyRequest>>,
    time: Res<Time>,
) {
    for FromClient { client_id, event } in lobby_requests.read() {
        match event {
            LobbyRequest::ListLobbies(request) => {
                // Handle lobby list request
                let filtered_lobbies = filter_lobbies(&lobby_server.lobbies, &request.filters);
                let response = ServerListResponse {
                    lobbies: filtered_lobbies,
                    total_lobbies: lobby_server.lobbies.len(),
                };
                
                // Send response to client
                server.send_message(*client_id, response);
            }
            LobbyRequest::CreateLobby(request) => {
                // Handle lobby creation request
                let lobby_id = generate_uuid();
                let client_info = lobby_server.clients.get_mut(client_id).unwrap();
                
                // Create new lobby
                let lobby = LobbyData {
                    info: LobbyInfo {
                        id: lobby_id.clone(),
                        name: request.name.clone(),
                        host_name: client_info.username.clone().unwrap_or_default(),
                        player_count: 1,
                        max_players: request.max_players,
                        has_password: request.password.is_some(),
                        format: request.format.clone(),
                        restrictions: request.restrictions.clone(),
                        description: request.description.clone(),
                    },
                    settings: request.settings.clone(),
                    players: HashMap::new(),
                    chat_history: Vec::new(),
                    created_at: time.elapsed_seconds(),
                    last_activity: time.elapsed_seconds(),
                };
                
                // Add host to the lobby
                let player = LobbyPlayer {
                    name: client_info.username.clone().unwrap_or_default(),
                    is_host: true,
                    status: PlayerLobbyState::Joined,
                    deck_info: None,
                };
                
                let mut players = HashMap::new();
                players.insert(player.name.clone(), player);
                
                lobby.players = players;
                
                // Add lobby to server
                lobby_server.lobbies.insert(lobby_id.clone(), lobby);
                
                // Update client info
                client_info.lobby_id = Some(lobby_id.clone());
                client_info.state = LobbyClientState::InLobby;
                
                // Send response to client
                server.send_message(*client_id, CreateLobbyResponse {
                    success: true,
                    lobby_id: Some(lobby_id),
                    error: None,
                });
            }
            LobbyRequest::JoinLobby(request) => {
                // Handle join lobby request
                if let Some(lobby) = lobby_server.lobbies.get_mut(&request.lobby_id) {
                    // Check if lobby is joinable
                    if lobby.players.len() >= lobby.info.max_players {
                        // Lobby is full
                        server.send_message(*client_id, JoinLobbyResponse {
                            success: false,
                            failure_reason: Some("Lobby is full".to_string()),
                            lobby_details: None,
                        });
                        continue;
                    }
                    
                    // Check password if required
                    if lobby.info.has_password {
                        // Verify password
                    }
                    
                    // Add player to lobby
                    let client_info = lobby_server.clients.get_mut(client_id).unwrap();
                    let player_name = client_info.username.clone().unwrap_or_default();
                    
                    let player = LobbyPlayer {
                        name: player_name.clone(),
                        is_host: false,
                        status: PlayerLobbyState::Joined,
                        deck_info: None,
                    };
                    
                    lobby.players.insert(player_name, player);
                    lobby.info.player_count = lobby.players.len();
                    lobby.last_activity = time.elapsed_seconds();
                    
                    // Update client info
                    client_info.lobby_id = Some(request.lobby_id.clone());
                    client_info.state = LobbyClientState::InLobby;
                    
                    // Add system message about player joining
                    lobby.chat_history.push(ChatMessage {
                        id: generate_uuid(),
                        sender: "System".to_string(),
                        is_system: true,
                        content: format!("{} has joined the lobby", player_name),
                        timestamp: time.elapsed_seconds(),
                    });
                    
                    // Notify all players in the lobby
                    notify_lobby_update(&mut server, &request.lobby_id, &lobby_server);
                    
                    // Send response to joining client
                    server.send_message(*client_id, JoinLobbyResponse {
                        success: true,
                        failure_reason: None,
                        lobby_details: Some(convert_to_lobby_details(lobby)),
                    });
                } else {
                    // Lobby not found
                    server.send_message(*client_id, JoinLobbyResponse {
                        success: false,
                        failure_reason: Some("Lobby not found".to_string()),
                        lobby_details: None,
                    });
                }
            }
            LobbyRequest::SendChat(request) => {
                // Handle chat message
                process_chat_message(client_id, request, &mut server, &mut lobby_server, &time);
            }
            LobbyRequest::UpdateStatus(request) => {
                // Handle player status update
                process_status_update(client_id, request, &mut server, &mut lobby_server, &time);
            }
            LobbyRequest::ViewDeck(request) => {
                // Handle deck view request
                process_deck_view_request(client_id, request, &mut server, &mut lobby_server);
            }
            LobbyRequest::LaunchGame(request) => {
                // Handle game launch request
                process_game_launch(client_id, request, &mut server, &mut lobby_server);
            }
        }
    }
}
}

Game Server Implementation

The Game Server handles the actual Commander gameplay after a match is initiated:

#![allow(unused)]
fn main() {
/// Main game server resource
#[derive(Resource)]
pub struct GameServer {
    /// Current game state
    pub game_state: Option<GameState>,
    /// Connected players
    pub players: HashMap<ClientId, Entity>,
    /// Whether this server is accepting new connections
    pub accepting_connections: bool,
    /// Game configuration
    pub config: GameConfig,
}

/// Start a new game instance
pub fn start_game_instance(
    mut commands: Commands,
    mut server: ResMut<RepliconServer>,
    game_launch: Res<GameLaunchInfo>,
) {
    // Initialize game state
    let game_state = initialize_game_state(&game_launch);
    commands.insert_resource(game_state.clone());
    
    // Configure server to accept connections from players
    commands.insert_resource(GameServer {
        game_state: Some(game_state),
        players: HashMap::new(),
        accepting_connections: true,
        config: game_launch.settings.clone(),
    });
    
    // Start server on specified port
    server.start_listening(game_launch.port);
    
    // Set up turn tracking
    commands.insert_resource(TurnManager::new());
    
    // Set up zones for cards
    commands.insert_resource(ZoneManager::new());
    
    // Notify lobby server that this game instance is ready
    notify_lobby_server_game_ready(game_launch.lobby_id.clone(), game_launch.port);
}

/// Transfer players from lobby to game
pub fn transfer_players_to_game(
    mut server: ResMut<RepliconServer>,
    game_server: Res<GameServer>,
    lobby_connection: Res<LobbyServerConnection>,
    game_launch: Res<GameLaunchInfo>,
) {
    // Notify all players in the lobby that the game is ready
    let connection_details = GameConnectionDetails {
        server_address: get_server_address(),
        game_id: game_launch.game_id.clone(),
        connection_token: generate_connection_tokens(&game_launch.players),
    };
    
    let notification = GameLaunchNotification {
        connection_details,
        players: game_launch.players.clone(),
        settings: game_launch.settings.clone(),
    };
    
    // Send notification to lobby server to distribute to players
    lobby_connection.send_message(LobbyServerMessage::GameReady(notification));
}
}

Connection and Protocol

The networking protocol uses WebRTC for UDP-based communication with reliability layers:

#![allow(unused)]
fn main() {
/// Connection protocol constants
pub mod protocol {
    /// Protocol version
    pub const PROTOCOL_VERSION: &str = "1.0.0";
    /// Maximum message size in bytes
    pub const MAX_MESSAGE_SIZE: usize = 1024 * 64;
    /// Heartbeat interval in seconds
    pub const HEARTBEAT_INTERVAL: f32 = 1.0;
    /// Connection timeout in seconds
    pub const CONNECTION_TIMEOUT: f32 = 5.0;
}

/// Initialize the networking protocol
pub fn init_networking(app: &mut App) {
    app
        .add_plugins(RepliconPlugin)
        .add_systems(Startup, setup_network_config)
        .add_systems(PreUpdate, (
            process_connection_events,
            handle_protocol_messages,
        ))
        .add_systems(Update, (
            send_heartbeats,
            check_timeouts,
        ));
}

/// Set up network configuration
fn setup_network_config(mut commands: Commands) {
    commands.insert_resource(RepliconConfig {
        max_message_size: protocol::MAX_MESSAGE_SIZE,
        max_message_channel_count: 3,
        ..default()
    });
}

/// Channel types for different message priorities
#[derive(Debug, PartialEq, Eq, Clone, Copy, Hash)]
pub enum NetworkChannel {
    /// Reliable ordered channel for important messages
    Reliable,
    /// Unreliable channel for position updates
    Unreliable,
    /// Ordered but can drop messages for less critical state
    ReliableUnordered,
}
}

Network Optimization

To handle the complexity of Commander games, we implement several optimization techniques:

  1. Delta Compression: Only send changes to game state
  2. Interest Management: Only sync relevant parts of the game state to each client
  3. Batched Updates: Collect multiple updates and send them together
  4. Prioritized Synchronization: Critical game events take priority over visual updates
#![allow(unused)]
fn main() {
/// Delta encoder for game state changes
pub struct DeltaEncoder {
    /// Previous game state hash
    previous_hash: u64,
    /// Component change tracking
    component_changes: HashMap<Entity, Vec<ComponentChange>>,
}

impl DeltaEncoder {
    /// Encode delta changes between states
    pub fn encode_delta(&mut self, current_state: &GameState) -> DeltaPacket {
        // Calculate changes since previous state
        // ...
        
        DeltaPacket {
            base_hash: self.previous_hash,
            entities_added: vec![],
            entities_removed: vec![],
            component_changes: self.component_changes.clone(),
        }
    }
}
}

Data Persistence

The lobby server persists certain data to provide continuity:

  1. User Accounts: Player profiles and authentication
  2. Lobby Templates: Saved lobby configurations
  3. Match History: Record of played games
  4. Deck Statistics: Anonymized deck performance data
#![allow(unused)]
fn main() {
/// Data persistence manager
pub struct PersistenceManager {
    /// Database connection
    db_connection: DbConnection,
    /// Cache for frequently accessed data
    cache: LruCache<String, CachedData>,
}

impl PersistenceManager {
    /// Save lobby to database
    pub async fn save_lobby(&self, lobby: &LobbyData) -> Result<(), DbError> {
        // Serialize and store lobby data
        // ...
        Ok(())
    }
    
    /// Load lobby from database
    pub async fn load_lobby(&self, lobby_id: &str) -> Result<Option<LobbyData>, DbError> {
        // Retrieve and deserialize lobby data
        // ...
        Ok(None)
    }
    
    /// Record match results
    pub async fn record_match_result(&self, results: &MatchResults) -> Result<(), DbError> {
        // Store match results for history and statistics
        // ...
        Ok(())
    }
}
}

Security Considerations

The multiplayer system implements several security measures:

  1. Authentication: Verify player identities
  2. Authorization: Control access to lobbies and games
  3. Input Validation: Sanitize all player input
  4. Rate Limiting: Prevent spam and DoS attacks
  5. Encryption: Secure sensitive communications
#![allow(unused)]
fn main() {
/// Security module for the lobby system
pub mod security {
    /// Validate player input
    pub fn validate_player_input(input: &str) -> bool {
        // Check for malicious content
        // ...
        true
    }
    
    /// Rate limit tracker
    pub struct RateLimiter {
        /// Action counts per client
        client_actions: HashMap<ClientId, Vec<(f64, ActionType)>>,
    }
    
    impl RateLimiter {
        /// Check if action is allowed
        pub fn check_rate_limit(&mut self, client_id: ClientId, action: ActionType, time: f64) -> bool {
            // Implement rate limiting logic
            // ...
            true
        }
    }
    
    /// Encrypt sensitive data
    pub fn encrypt_data(data: &[u8], key: &[u8]) -> Vec<u8> {
        // Encryption implementation
        // ...
        Vec::new()
    }
}
}

Testing and Validation

The lobby system includes comprehensive testing:

  1. Unit Tests: Verify individual component behavior
  2. Integration Tests: Test component interactions
  3. Load Tests: Ensure system can handle many concurrent lobbies
  4. Latency Simulation: Test under various network conditions
  5. Security Tests: Verify system resilience against attacks
#![allow(unused)]
fn main() {
/// Test suite for the lobby system
#[cfg(test)]
mod tests {
    use super::*;
    
    /// Test lobby creation and joining
    #[test]
    fn test_lobby_lifecycle() {
        // Set up test environment
        let mut app = App::new();
        app.add_plugins(MinimalPlugins)
            .add_plugin(LobbyPlugin);
        
        // Create a lobby
        // ...
        
        // Join the lobby
        // ...
        
        // Verify state
        // ...
    }
    
    /// Test chat functionality
    #[test]
    fn test_chat_system() {
        // Set up test environment
        // ...
        
        // Send chat messages
        // ...
        
        // Verify delivery
        // ...
    }
    
    /// Test game launch process
    #[test]
    fn test_game_launch() {
        // Set up test environment
        // ...
        
        // Ready up players
        // ...
        
        // Launch game
        // ...
        
        // Verify transition
        // ...
    }
}
}

Deployment Considerations

The lobby system supports various deployment scenarios:

  1. Self-Hosted: Players can host their own servers
  2. Dedicated Servers: Centralized infrastructure
  3. Hybrid Model: Official servers plus community hosting
  4. Cloud Deployment: Scalable containers for peak times
#![allow(unused)]
fn main() {
/// Deployment configuration
pub struct DeploymentConfig {
    /// Server discovery method
    pub discovery: ServerDiscoveryMethod,
    /// Server capacity
    pub capacity: ServerCapacity,
    /// Geographic region
    pub region: String,
    /// Auto-scaling settings
    pub scaling: Option<ScalingConfig>,
}

/// Server capacity configuration
pub struct ServerCapacity {
    /// Maximum concurrent lobbies
    pub max_lobbies: usize,
    /// Maximum concurrent games
    pub max_games: usize,
    /// Maximum players per server
    pub max_players: usize,
}

/// Auto-scaling configuration
pub struct ScalingConfig {
    /// Minimum number of instances
    pub min_instances: usize,
    /// Maximum number of instances
    pub max_instances: usize,
    /// Scale up threshold (% utilization)
    pub scale_up_threshold: f32,
    /// Scale down threshold (% utilization)
    pub scale_down_threshold: f32,
}
}

This document provides a comprehensive overview of the server-side implementation for the multiplayer lobby system, including the necessary architectural components and security considerations.

Multiplayer Lobby System

This document outlines the networking architecture for the Commander format multiplayer lobby system. The lobby system enables players to discover, join, and configure games before starting a match.

Table of Contents

  1. System Overview
  2. Lobby Discovery
  3. Lobby Information and Browsing
  4. Joining and Managing Lobbies
  5. Chat System
  6. Ready-Up Mechanism
  7. Deck and Commander Viewing
  8. Game Launch
  9. Connection Protocol
  10. Implementation Details

System Overview

The lobby system serves as the pre-game matchmaking component of the multiplayer experience. It allows players to:

  1. Browse available game lobbies
  2. Create new lobbies with custom settings
  3. Join existing lobbies
  4. Chat with other players
  5. View other players' decks and commanders
  6. Ready up for the game
  7. Launch into a Commander game once all players are ready
#![allow(unused)]
fn main() {
/// The main states of the lobby system
#[derive(States, Debug, Clone, Copy, Eq, PartialEq, Hash)]
pub enum LobbyState {
    /// Lobby browser showing available games
    Browser,
    /// Inside a specific lobby
    InLobby,
    /// Transitioning to game
    LaunchingGame,
}

/// Resource tracking the active lobby connection
#[derive(Resource, Default)]
pub struct LobbyConnection {
    /// ID of the connected lobby or None if browsing
    pub current_lobby_id: Option<String>,
    /// Whether the player is the host of the current lobby
    pub is_host: bool,
    /// The server address this lobby is hosted on
    pub server_address: Option<String>,
}
}

Lobby Discovery

Players can discover lobbies through two primary methods:

  1. Server List: Connect to a lobby server that hosts multiple lobbies
  2. Direct IP: Connect directly to a specific lobby host
#![allow(unused)]
fn main() {
/// Methods for discovering lobbies
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LobbyDiscoveryMethod {
    /// Connect to a lobby server
    ServerList(String), // Server address
    /// Connect directly to a host
    DirectIp(String),   // IP address and port
}

/// Server list request message
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct ServerListRequest {
    /// Client version
    pub version: String,
    /// Optional filter parameters
    pub filters: Option<LobbyFilters>,
}

/// Server list response message
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct ServerListResponse {
    /// List of available lobbies
    pub lobbies: Vec<LobbyInfo>,
    /// Total number of lobbies on the server
    pub total_lobbies: usize,
}
}

Lobby Browser UI

The lobby browser presents a list of available lobbies with key information:

  1. Lobby name
  2. Host name
  3. Current player count / maximum players
  4. Commander format details (standard, cEDH, etc.)
  5. Special restrictions or rules
  6. Whether the lobby is password-protected

Lobby Information and Browsing

Lobbies include various pieces of information for players to browse:

#![allow(unused)]
fn main() {
/// Information about a lobby visible in the browser
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct LobbyInfo {
    /// Unique identifier for the lobby
    pub id: String,
    /// Displayed name of the lobby
    pub name: String,
    /// Host player's name
    pub host_name: String,
    /// Current number of players
    pub player_count: usize,
    /// Maximum allowed players
    pub max_players: usize,
    /// Whether the lobby is password protected
    pub has_password: bool,
    /// Format information
    pub format: CommanderFormat,
    /// Game rules and restrictions
    pub restrictions: GameRestrictions,
    /// Brief description provided by the host
    pub description: Option<String>,
}

/// Commander format details
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
pub enum CommanderFormat {
    /// Standard Commander rules
    Standard,
    /// Competitive EDH
    CEDH,
    /// Commander Variant (e.g., Brawl, Oathbreaker)
    Variant(String),
    /// Custom rule set
    Custom,
}

/// Game restrictions and rule modifications
#[derive(Serialize, Deserialize, Clone, Debug, Default)]
pub struct GameRestrictions {
    /// Card pool restrictions (e.g., budget, no infinites)
    pub card_pool: Vec<String>,
    /// Deck construction rules
    pub deck_rules: Vec<String>,
    /// House rules for gameplay
    pub game_rules: Vec<String>,
}
}

Joining and Managing Lobbies

When a player selects a lobby, they can view detailed information before joining:

#![allow(unused)]
fn main() {
/// Detailed lobby information shown when selected
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct LobbyDetails {
    /// Basic lobby info
    pub info: LobbyInfo,
    /// Detailed description
    pub full_description: String,
    /// Current players in the lobby
    pub players: Vec<LobbyPlayerInfo>,
    /// Expected game duration
    pub estimated_duration: Option<String>,
    /// Additional custom settings
    pub custom_settings: HashMap<String, String>,
}

/// Join request message
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct JoinLobbyRequest {
    /// Lobby to join
    pub lobby_id: String,
    /// Player name
    pub player_name: String,
    /// Password if required
    pub password: Option<String>,
}

/// Join response message
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct JoinLobbyResponse {
    /// Whether the join was successful
    pub success: bool,
    /// Reason for failure if unsuccessful
    pub failure_reason: Option<String>,
    /// Lobby details if successful
    pub lobby_details: Option<LobbyDetails>,
}
}

Chat System

The lobby includes a chat system to allow players to communicate:

#![allow(unused)]
fn main() {
/// Chat message in a lobby
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct ChatMessage {
    /// Message ID for tracking
    pub id: String,
    /// Sender of the message
    pub sender: String,
    /// Whether the message is from the system
    pub is_system: bool,
    /// Message content
    pub content: String,
    /// Timestamp
    pub timestamp: f64,
}

/// Chat message request
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct SendChatRequest {
    /// Lobby ID
    pub lobby_id: String,
    /// Message content
    pub content: String,
}
}

The chat system should support:

  • Player-to-player messages
  • System announcements
  • Emoji/reactions
  • Message history

Ready-Up Mechanism

Players need to "ready up" before a game can start:

#![allow(unused)]
fn main() {
/// Player state in the lobby
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
pub enum PlayerLobbyState {
    /// Just joined, not ready
    Joined,
    /// Selecting deck
    SelectingDeck,
    /// Ready with deck selected
    Ready,
}

/// Ready status update
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct ReadyStatusUpdate {
    /// Player changing status
    pub player_name: String,
    /// New status
    pub status: PlayerLobbyState,
    /// Selected deck info if ready
    pub deck_info: Option<DeckInfo>,
}
}

The host can only start the game when all players are in the Ready state.

Deck and Commander Viewing

Players can view each other's decks and commanders:

#![allow(unused)]
fn main() {
/// Basic deck information
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct DeckInfo {
    /// Deck name
    pub name: String,
    /// Commander card
    pub commander: CommanderInfo,
    /// Partner commander if applicable
    pub partner: Option<CommanderInfo>,
    /// Deck color identity
    pub colors: Vec<String>,
    /// Card count
    pub card_count: usize,
    /// Average mana value
    pub avg_mana_value: f32,
    /// Deck power level (1-10)
    pub power_level: u8,
    /// Whether to share full decklist
    pub share_decklist: bool,
}

/// Commander card information
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct CommanderInfo {
    /// Card name
    pub name: String,
    /// Mana cost
    pub mana_cost: String,
    /// Card type
    pub type_line: String,
    /// Rules text
    pub text: String,
    /// Power/toughness if applicable
    pub power_toughness: Option<String>,
    /// Card image URI
    pub image_uri: Option<String>,
}

/// Request to view a full decklist
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct DeckViewRequest {
    /// Player whose deck to view
    pub player_name: String,
}

/// Full decklist response
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct DeckViewResponse {
    /// Basic deck info
    pub info: DeckInfo,
    /// Full card list if shared
    pub card_list: Option<Vec<CardInfo>>,
    /// Reason if not shared
    pub not_shared_reason: Option<String>,
}
}

Game Launch

When all players are ready, the host can launch the game:

#![allow(unused)]
fn main() {
/// Game launch request (host only)
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct LaunchGameRequest {
    /// Lobby ID
    pub lobby_id: String,
}

/// Game launch notification to all players
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct GameLaunchNotification {
    /// Game connection details
    pub connection_details: GameConnectionDetails,
    /// Final player list
    pub players: Vec<LobbyPlayerInfo>,
    /// Game settings
    pub settings: GameSettings,
}

/// Connection details for the actual game
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct GameConnectionDetails {
    /// Game server address
    pub server_address: String,
    /// Game ID
    pub game_id: String,
    /// Connection token
    pub connection_token: String,
}
}

Connection Protocol

The lobby system uses a reliable TCP connection for all communications:

  1. Player connects to a lobby server
  2. Authentication (if required)
  3. Request lobby list
  4. Select and join a lobby
  5. Participate in lobby activities
  6. Ready up when prepared
  7. Transition to game when launched

Connection States

#![allow(unused)]
fn main() {
/// Connection state machine
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LobbyConnectionState {
    /// Not connected
    Disconnected,
    /// Attempting to connect
    Connecting,
    /// Connected and authenticated
    Connected,
    /// Browsing available lobbies
    Browsing,
    /// Inside a specific lobby
    InLobby,
    /// Preparing to launch game
    PreLaunch,
    /// Transitioning to game
    Launching,
    /// Connection error
    Error,
}
}

Implementation Details

The lobby system will be implemented using Bevy's ECS architecture with these key components:

Lobby Browser Scene

#![allow(unused)]
fn main() {
pub fn setup_lobby_browser(
    mut commands: Commands,
    asset_server: Res<AssetServer>,
    lobbies: Res<AvailableLobbies>,
) {
    // Main container
    commands
        .spawn((
            Node {
                width: Val::Percent(100.0),
                height: Val::Percent(100.0),
                flex_direction: FlexDirection::Column,
                ..default()
            },
            LobbyBrowserUI,
        ))
        .with_children(|parent| {
            // Header
            parent.spawn(Node {
                width: Val::Percent(100.0),
                height: Val::Px(80.0),
                justify_content: JustifyContent::SpaceBetween,
                align_items: AlignItems::Center,
                padding: UiRect::all(Val::Px(20.0)),
                ..default()
            }).with_children(|header| {
                // Title
                header.spawn(Text2d {
                    text: "Multiplayer Lobbies".into(),
                    // Add styling...
                });
                
                // Controls (Refresh, Create Lobby, etc.)
                header.spawn(Node {
                    width: Val::Auto,
                    height: Val::Px(40.0),
                    flex_direction: FlexDirection::Row,
                    ..default()
                }).with_children(|controls| {
                    // Refresh button
                    // Create lobby button
                    // Direct connect field
                });
            });
            
            // Lobby list
            parent.spawn(Node {
                width: Val::Percent(100.0),
                height: Val::Percent(70.0),
                flex_direction: FlexDirection::Column,
                overflow: Overflow::Scroll,
                ..default()
            }).with_children(|list| {
                // Render each lobby as a selectable item
                for lobby in &lobbies.list {
                    create_lobby_list_item(list, lobby, &asset_server);
                }
            });
            
            // Details panel
            parent.spawn(Node {
                width: Val::Percent(100.0),
                height: Val::Percent(30.0),
                ..default()
            }).with_children(|details| {
                // Show selected lobby details
                // Join button
            });
        });
}

/// System to handle refreshing the lobby list
pub fn refresh_lobby_list(
    mut lobby_query: EventReader<RefreshLobbyListEvent>,
    mut connection: ResMut<LobbyConnection>,
    mut lobbies: ResMut<AvailableLobbies>,
) {
    for _ in lobby_query.read() {
        // Send request to server
        // Handle response and update lobbies.list
    }
}
}

Lobby Detail Scene

#![allow(unused)]
fn main() {
pub fn setup_lobby_detail(
    mut commands: Commands,
    asset_server: Res<AssetServer>,
    lobby_details: Res<CurrentLobbyDetails>,
) {
    // Main container
    commands
        .spawn((
            Node {
                width: Val::Percent(100.0),
                height: Val::Percent(100.0),
                flex_direction: FlexDirection::Row,
                ..default()
            },
            LobbyDetailUI,
        ))
        .with_children(|parent| {
            // Left panel (player list and details)
            parent.spawn(Node {
                width: Val::Percent(30.0),
                height: Val::Percent(100.0),
                flex_direction: FlexDirection::Column,
                ..default()
            }).with_children(|left_panel| {
                // Lobby info
                // Player list
                // Ready button
            });
            
            // Center panel (chat)
            parent.spawn(Node {
                width: Val::Percent(40.0),
                height: Val::Percent(100.0),
                flex_direction: FlexDirection::Column,
                ..default()
            }).with_children(|center_panel| {
                // Chat history
                // Chat input
            });
            
            // Right panel (deck viewer)
            parent.spawn(Node {
                width: Val::Percent(30.0),
                height: Val::Percent(100.0),
                flex_direction: FlexDirection::Column,
                ..default()
            }).with_children(|right_panel| {
                // Selected deck info
                // Commander view
                // Deck stats
            });
        });
}

/// System to send chat messages
pub fn send_chat_message(
    mut chat_events: EventReader<SendChatEvent>,
    mut connection: ResMut<LobbyConnection>,
) {
    for event in chat_events.read() {
        // Format and send chat message to server
    }
}

/// System to update player ready status
pub fn update_ready_status(
    mut ready_events: EventReader<ReadyStatusEvent>,
    mut connection: ResMut<LobbyConnection>,
) {
    for event in ready_events.read() {
        // Update local state
        // Send ready status to server
    }
}
}

Game Launch

#![allow(unused)]
fn main() {
pub fn launch_game(
    mut launch_events: EventReader<LaunchGameEvent>,
    mut connection: ResMut<LobbyConnection>,
    mut next_state: ResMut<NextState<GameMenuState>>,
) {
    for event in launch_events.read() {
        // Handle game launch
        // Transition to Loading state
        next_state.set(GameMenuState::Loading);
    }
}
}

Systems and Resources

The lobby system will use these key Bevy systems and resources:

#![allow(unused)]
fn main() {
/// Plugin to register all lobby-related systems
pub struct LobbyPlugin;

impl Plugin for LobbyPlugin {
    fn build(&self, app: &mut App) {
        app
            // States
            .add_state::<LobbyState>()
            
            // Resources
            .init_resource::<LobbyConnection>()
            .init_resource::<AvailableLobbies>()
            .init_resource::<CurrentLobbyDetails>()
            .init_resource::<ChatHistory>()
            
            // Events
            .add_event::<RefreshLobbyListEvent>()
            .add_event::<JoinLobbyEvent>()
            .add_event::<LeaveLobbyEvent>()
            .add_event::<SendChatEvent>()
            .add_event::<ReadyStatusEvent>()
            .add_event::<ViewDeckEvent>()
            .add_event::<LaunchGameEvent>()
            
            // Systems
            .add_systems(OnEnter(LobbyState::Browser), setup_lobby_browser)
            .add_systems(OnExit(LobbyState::Browser), cleanup_lobby_browser)
            .add_systems(OnEnter(LobbyState::InLobby), setup_lobby_detail)
            .add_systems(OnExit(LobbyState::InLobby), cleanup_lobby_detail)
            
            .add_systems(Update, 
                (
                    handle_lobby_connections,
                    refresh_lobby_list,
                    process_lobby_messages,
                ).run_if(in_state(LobbyState::Browser))
            )
            
            .add_systems(Update, 
                (
                    process_lobby_messages,
                    send_chat_message,
                    update_ready_status,
                    handle_deck_viewing,
                    launch_game,
                ).run_if(in_state(LobbyState::InLobby))
            );
    }
}
}

Integration with Main Menu

The multiplayer button in the main menu will trigger a transition to the lobby browser:

#![allow(unused)]
fn main() {
pub fn menu_action(
    mut interaction_query: Query<
        (&Interaction, &MenuButtonAction, &mut BackgroundColor),
        (Changed<Interaction>, With<Button>),
    >,
    mut next_state: ResMut<NextState<GameMenuState>>,
    mut lobby_state: ResMut<NextState<LobbyState>>,
    mut exit: EventWriter<bevy::app::AppExit>,
) {
    for (interaction, action, mut color) in &mut interaction_query {
        if *interaction == Interaction::Pressed {
            match action {
                MenuButtonAction::Multiplayer => {
                    // Transition to lobby browser
                    lobby_state.set(LobbyState::Browser);
                    // Note: We might need a new GameMenuState for multiplayer
                }
                // Other actions...
            }
        }
    }
}
}

This document provides a comprehensive overview of the multiplayer lobby system design, including the necessary components and systems to implement it within the Bevy ECS architecture. The implementation details can be expanded as needed during development.

Lobby Chat System

This section covers the chat functionality available in the game lobby, allowing players to communicate before a game begins.

Overview

The lobby chat system provides text-based communication between players in the lobby, supporting both global chat and game room-specific discussions.

Components

UI

Details of the chat user interface, including:

  • Chat window layout and design
  • Message display formatting
  • Input mechanisms
  • Notification systems
  • Emoji and shortcut support
  • Chat history and scrollback

Features

  • Global lobby chat for all connected players
  • Game room-specific chat for players in the same game
  • Basic moderation features
  • Friend-to-friend direct messages
  • System announcements and notifications
  • Chat history persistence

Integration

The lobby chat system integrates with:

Lobby Chat System UI

This document details the chat system UI used in multiplayer lobbies, which allows players to communicate before starting a Commander game. The chat system is crucial for discussing deck power levels, house rules, and general coordination.

Table of Contents

  1. Overview
  2. UI Components
  3. Message Types
  4. Features
  5. Implementation

Overview

The chat system occupies the central panel of the lobby detail screen, providing a real-time communication channel between all players in the lobby. It supports text messages, system notifications, and special formatting for enhanced communication.

┌───────────────────────────────────┐
│          Chat Panel               │
├───────────────────────────────────┤
│                                   │
│  [System] Player2 has joined      │
│                                   │
│  Player1: Hello everyone!         │
│                                   │
│  Player2: Hey, what power level   │
│  are we playing at?               │
│                                   │
│  Player1: Casual, around 6-7      │
│                                   │
│  [System] Player3 has joined      │
│                                   │
│  Player3: I have a deck that      │
│  should work for that             │
│                                   │
│                                   │
├───────────────────────────────────┤
│ ┌─────────────────────────┐  ┌──┐ │
│ │ Type a message...       │  │▶ │ │
│ └─────────────────────────┘  └──┘ │
└───────────────────────────────────┘

UI Components

The chat UI consists of several key components:

Message Display Area

  • Scrollable container for message history
  • Message bubbles with sender identification
  • System messages with distinctive styling
  • Timestamp indicators
  • Auto-scrolling to newest messages

Input Area

  • Text input field
  • Send button
  • Character counter
  • Emoji selector

Additional Controls

  • Chat options button (opens chat settings)
  • Notification indicators for new messages
  • Mentions highlighting

Message Types

The chat system supports various message types:

#![allow(unused)]
fn main() {
/// Types of messages in the lobby chat
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum MessageType {
    /// Regular player chat message
    Chat,
    /// System notification
    System,
    /// Direct message to specific player(s)
    DirectMessage,
    /// Game announcement (like deck selection)
    Announcement,
    /// Error message
    Error,
}

/// Chat message component
#[derive(Component, Clone, Debug)]
pub struct ChatMessage {
    /// Unique identifier
    pub id: String,
    /// Sender name (if applicable)
    pub sender: Option<String>,
    /// Message content
    pub content: String,
    /// Type of message
    pub message_type: MessageType,
    /// Timestamp when message was sent
    pub timestamp: f64,
    /// Players mentioned in the message
    pub mentions: Vec<String>,
}
}

Features

Real-time Updates

The chat system provides real-time message delivery with visual indicators:

#![allow(unused)]
fn main() {
/// System to add new messages to the chat
fn add_chat_message(
    mut commands: Commands,
    mut message_events: EventReader<NewChatMessageEvent>,
    mut chat_state: ResMut<ChatState>,
    time: Res<Time>,
    asset_server: Res<AssetServer>,
) {
    for event in message_events.read() {
        // Create the new message entity
        let message_entity = commands
            .spawn(ChatMessage {
                id: generate_uuid(),
                sender: event.sender.clone(),
                content: event.content.clone(),
                message_type: event.message_type.clone(),
                timestamp: time.elapsed_seconds(),
                mentions: find_mentions(&event.content),
            })
            .id();
            
        // Add to message history
        chat_state.messages.push(message_entity);
        
        // Trim history if too long
        if chat_state.messages.len() > MAX_CHAT_HISTORY {
            if let Some(old_message) = chat_state.messages.pop_front() {
                commands.entity(old_message).despawn();
            }
        }
        
        // Play notification sound if appropriate
        if needs_notification(&event.message_type, &event.mentions) {
            commands.spawn(AudioSource {
                source: asset_server.load("sounds/chat_notification.ogg"),
                ..default()
            });
        }
    }
}
}

Message Formatting

Players can use basic formatting in their messages:

  • Bold text: Using **text** syntax
  • Italics: Using *text* syntax
  • Code blocks: Using code syntax
  • Links: URLs are automatically detected and made clickable
#![allow(unused)]
fn main() {
/// Process message formatting
fn process_message_formatting(
    mut message_query: Query<(&mut Text, &ChatMessage), Added<ChatMessage>>,
) {
    for (mut text, message) in &mut message_query {
        let formatted_content = format_message_content(&message.content);
        
        // Apply formatting to text component
        text.sections[0].value = formatted_content;
        
        // Apply styling based on message type
        match message.message_type {
            MessageType::System => {
                text.sections[0].style.color = SYSTEM_MESSAGE_COLOR;
            }
            MessageType::Error => {
                text.sections[0].style.color = ERROR_MESSAGE_COLOR;
            }
            MessageType::Announcement => {
                text.sections[0].style.color = ANNOUNCEMENT_COLOR;
            }
            _ => {
                // Regular chat styling
            }
        }
    }
}
}

Mentions

Players can mention other players with the @username syntax:

#![allow(unused)]
fn main() {
/// Find and extract mentions from message content
fn find_mentions(content: &str) -> Vec<String> {
    let mut mentions = Vec::new();
    
    // Regular expression to find @mentions
    let re = Regex::new(r"@(\w+)").unwrap();
    for capture in re.captures_iter(content) {
        if let Some(username) = capture.get(1) {
            mentions.push(username.as_str().to_string());
        }
    }
    
    mentions
}

/// Highlight mentions in messages
fn highlight_mentions(
    mut message_query: Query<(&mut Text, &ChatMessage), Added<ChatMessage>>,
    local_player: Res<LocalPlayer>,
) {
    for (mut text, message) in &mut message_query {
        // Check if local player is mentioned
        if message.mentions.contains(&local_player.name) {
            // Highlight the entire message
            text.sections[0].style.color = MENTION_HIGHLIGHT_COLOR;
        }
    }
}
}

Chat Filtering

The system includes filtering options for inappropriate content:

#![allow(unused)]
fn main() {
/// Filter message content for inappropriate language
fn filter_message_content(content: &str) -> String {
    let mut filtered = content.to_string();
    
    // Apply word filters
    for (pattern, replacement) in INAPPROPRIATE_WORDS.iter() {
        let re = Regex::new(pattern).unwrap();
        filtered = re.replace_all(&filtered, replacement).to_string();
    }
    
    filtered
}
}

Notifications

Players receive notifications for new messages, especially mentions:

#![allow(unused)]
fn main() {
/// System to handle chat notifications
fn update_chat_notifications(
    message_query: Query<&ChatMessage, Added<ChatMessage>>,
    mut notification_state: ResMut<NotificationState>,
    local_player: Res<LocalPlayer>,
    lobby_state: Res<LobbyState>,
) {
    for message in &message_query {
        // Check if the player is mentioned
        if message.mentions.contains(&local_player.name) {
            notification_state.add_notification(NotificationType::ChatMention {
                sender: message.sender.clone().unwrap_or_default(),
                preview: truncate_message(&message.content, 30),
            });
        }
        
        // If chat is not focused, increment unread counter
        if !lobby_state.is_chat_focused {
            notification_state.unread_chat_messages += 1;
        }
    }
}
}

Implementation

The chat system is implemented using Bevy's ECS architecture:

Chat Panel Setup

#![allow(unused)]
fn main() {
/// Set up the chat panel UI
pub fn setup_chat_panel(
    parent: &mut ChildBuilder,
    asset_server: &Res<AssetServer>,
) {
    // Chat panel container
    parent
        .spawn(Node {
            width: Val::Percent(40.0),
            height: Val::Percent(100.0),
            flex_direction: FlexDirection::Column,
            padding: UiRect::all(Val::Px(10.0)),
            ..default()
        })
        .with_children(|chat_panel| {
            // Chat header
            chat_panel.spawn(Node {
                width: Val::Percent(100.0),
                height: Val::Px(30.0),
                justify_content: JustifyContent::Center,
                align_items: AlignItems::Center,
                margin: UiRect::bottom(Val::Px(10.0)),
                ..default()
            }).with_children(|header| {
                header.spawn(Text2d {
                    text: "CHAT".into(),
                    font: asset_server.load("fonts/FiraSans-Bold.ttf"),
                    font_size: 18.0,
                    color: Color::WHITE,
                });
            });
            
            // Message display area
            chat_panel
                .spawn((
                    Node {
                        width: Val::Percent(100.0),
                        height: Val::Percent(85.0),
                        flex_direction: FlexDirection::Column,
                        overflow: Overflow::Scroll,
                        ..default()
                    },
                    ChatMessageContainer,
                ))
                .with_children(|messages| {
                    // Messages will be spawned here dynamically
                });
                
            // Input area
            chat_panel
                .spawn(Node {
                    width: Val::Percent(100.0),
                    height: Val::Px(40.0),
                    flex_direction: FlexDirection::Row,
                    margin: UiRect::top(Val::Px(10.0)),
                    align_items: AlignItems::Center,
                    ..default()
                })
                .with_children(|input_area| {
                    // Text input field
                    input_area
                        .spawn((
                            Node {
                                width: Val::Percent(85.0),
                                height: Val::Percent(100.0),
                                padding: UiRect::all(Val::Px(5.0)),
                                ..default()
                            },
                            BackgroundColor(Color::rgba(0.1, 0.1, 0.1, 0.7)),
                            BorderColor(Color::rgba(0.5, 0.5, 0.5, 0.7)),
                            Outline::new(Val::Px(1.0)),
                            ChatInputField,
                        ))
                        .with_children(|input| {
                            input.spawn((
                                Text2d {
                                    text: "Type a message...".into(),
                                    font: asset_server.load("fonts/FiraSans-Regular.ttf"),
                                    font_size: 14.0,
                                    color: Color::rgba(0.7, 0.7, 0.7, 1.0),
                                },
                                ChatInputPlaceholder,
                            ));
                        });
                        
                    // Send button
                    input_area
                        .spawn((
                            Button,
                            Node {
                                width: Val::Percent(15.0),
                                height: Val::Percent(100.0),
                                justify_content: JustifyContent::Center,
                                align_items: AlignItems::Center,
                                margin: UiRect::left(Val::Px(5.0)),
                                ..default()
                            },
                            BackgroundColor(Color::rgba(0.2, 0.4, 0.8, 0.7)),
                            ChatSendButton,
                        ))
                        .with_children(|button| {
                            button.spawn(Text2d {
                                text: "▶".into(),
                                font: asset_server.load("fonts/FiraSans-Bold.ttf"),
                                font_size: 18.0,
                                color: Color::WHITE,
                            });
                        });
                });
        });
}
}

Message Rendering

#![allow(unused)]
fn main() {
/// System to render chat messages
fn render_chat_messages(
    mut commands: Commands,
    asset_server: Res<AssetServer>,
    chat_state: Res<ChatState>,
    mut message_container_query: Query<Entity, With<ChatMessageContainer>>,
    message_query: Query<(Entity, &ChatMessage)>,
    time: Res<Time>,
) {
    if chat_state.is_dirty {
        // Clear existing messages
        if let Ok(container_entity) = message_container_query.get_single_mut() {
            commands.entity(container_entity).despawn_descendants();
            
            // Re-spawn all messages
            commands.entity(container_entity).with_children(|parent| {
                for &message_entity in &chat_state.messages {
                    if let Ok((_, message)) = message_query.get(message_entity) {
                        spawn_message_ui(parent, message, &asset_server, time.elapsed_seconds());
                    }
                }
            });
        }
        
        // Mark as clean
        chat_state.is_dirty = false;
    }
}

/// Spawn a single chat message UI element
fn spawn_message_ui(
    parent: &mut ChildBuilder,
    message: &ChatMessage,
    asset_server: &Res<AssetServer>,
    current_time: f64,
) {
    // Calculate relative time for display
    let time_ago = format_time_ago(current_time - message.timestamp);
    
    // Message container
    parent
        .spawn((
            Node {
                width: Val::Percent(100.0),
                height: Val::Auto,
                flex_direction: FlexDirection::Column,
                margin: UiRect::bottom(Val::Px(5.0)),
                padding: UiRect::all(Val::Px(5.0)),
                ..default()
            },
            BackgroundColor(get_message_background_color(&message.message_type)),
            BorderColor(get_message_border_color(&message.message_type)),
            Outline::new(Val::Px(1.0)),
            RenderChatMessage(message.id.clone()),
        ))
        .with_children(|message_container| {
            // Header with sender and timestamp
            message_container
                .spawn(Node {
                    width: Val::Percent(100.0),
                    height: Val::Auto,
                    flex_direction: FlexDirection::Row,
                    justify_content: JustifyContent::SpaceBetween,
                    ..default()
                })
                .with_children(|header| {
                    // Sender name
                    if let Some(sender) = &message.sender {
                        header.spawn(Text2d {
                            text: format!("{}", sender),
                            font: asset_server.load("fonts/FiraSans-Bold.ttf"),
                            font_size: 14.0,
                            color: get_sender_color(sender),
                        });
                    } else {
                        header.spawn(Text2d {
                            text: "System".into(),
                            font: asset_server.load("fonts/FiraSans-Bold.ttf"),
                            font_size: 14.0,
                            color: SYSTEM_NAME_COLOR,
                        });
                    }
                    
                    // Timestamp
                    header.spawn(Text2d {
                        text: time_ago,
                        font: asset_server.load("fonts/FiraSans-Italic.ttf"),
                        font_size: 12.0,
                        color: Color::rgba(0.7, 0.7, 0.7, 1.0),
                    });
                });
                
            // Message content
            message_container.spawn(Text2d {
                text: message.content.clone(),
                font: asset_server.load("fonts/FiraSans-Regular.ttf"),
                font_size: 14.0,
                color: get_message_text_color(&message.message_type),
            });
        });
}
}

Input Handling

#![allow(unused)]
fn main() {
/// System to handle chat input
fn handle_chat_input(
    mut char_input_events: EventReader<ReceivedCharacter>,
    keyboard_input: Res<Input<KeyCode>>,
    mut chat_state: ResMut<ChatState>,
    mut chat_events: EventWriter<SendChatMessageEvent>,
    local_player: Res<LocalPlayer>,
) {
    if chat_state.is_input_focused {
        // Process character input
        for event in char_input_events.read() {
            if !event.char.is_control() {
                chat_state.current_input.push(event.char);
            }
        }
        
        // Handle backspace
        if keyboard_input.just_pressed(KeyCode::Backspace) && !chat_state.current_input.is_empty() {
            chat_state.current_input.pop();
        }
        
        // Handle Enter to send
        if keyboard_input.just_pressed(KeyCode::Return) && !chat_state.current_input.is_empty() {
            // Create chat message event
            chat_events.send(SendChatMessageEvent {
                content: chat_state.current_input.clone(),
                sender: local_player.name.clone(),
                message_type: MessageType::Chat,
            });
            
            // Clear input
            chat_state.current_input.clear();
        }
    }
}

/// System to handle send button clicks
fn handle_send_button(
    mut interaction_query: Query<&Interaction, (Changed<Interaction>, With<ChatSendButton>)>,
    mut chat_state: ResMut<ChatState>,
    mut chat_events: EventWriter<SendChatMessageEvent>,
    local_player: Res<LocalPlayer>,
) {
    for interaction in &mut interaction_query {
        if *interaction == Interaction::Pressed && !chat_state.current_input.is_empty() {
            // Create chat message event
            chat_events.send(SendChatMessageEvent {
                content: chat_state.current_input.clone(),
                sender: local_player.name.clone(),
                message_type: MessageType::Chat,
            });
            
            // Clear input
            chat_state.current_input.clear();
        }
    }
}
}

The chat system provides an essential communication channel for players to coordinate before starting their Commander game, helping ensure a fun and balanced gaming experience.

Lobby Deck System

This section covers the deck management functionality in the game lobby, allowing players to select, view, and validate decks before starting a game.

Overview

The lobby deck system provides interfaces for players to interact with their deck collection before a game begins, ensuring that selected decks meet format requirements.

Components

Viewer

Details of the deck viewer interface, including:

  • Deck list display
  • Card preview functionality
  • Deck statistics and analysis
  • Format legality checking
  • Commander and color identity validation
  • Deck sharing options

Features

  • Deck selection from player's collection
  • Format validation for Commander rules
  • Deck statistics and composition analysis
  • Last-minute deck adjustments
  • Deck sharing with other players
  • Deck import/export functionality

Integration

The deck system integrates with:

Lobby Deck Viewer UI

This document describes the deck viewer UI component in the multiplayer lobby. This feature allows players to view each other's commanders and deck information before starting a game, which helps ensure balanced gameplay and appropriate power levels.

Table of Contents

  1. Overview
  2. UI Components
  3. Privacy Controls
  4. Commander Preview
  5. Deck Statistics
  6. Implementation

Overview

The deck viewer occupies the right panel of the lobby detail screen, providing a preview of selected decks and their commanders. This feature promotes transparency and helps players ensure they're entering a game with compatible deck power levels.

┌───────────────────────────────────┐
│         Deck Viewer               │
├───────────────────────────────────┤
│                                   │
│ ┌─────────────────────────────┐   │
│ │                             │   │
│ │                             │   │
│ │     Commander Card          │   │
│ │                             │   │
│ │                             │   │
│ └─────────────────────────────┘   │
│                                   │
│ Player: Player2                   │
│ Deck: "Competitive Elves"         │
│                                   │
│ Color Identity: G                 │
│ Average CMC: 2.8                  │
│ Power Level: 7                    │
│                                   │
│ ┌─────────────────────────────┐   │
│ │  View Full Decklist (15/60) │   │
│ └─────────────────────────────┘   │
└───────────────────────────────────┘

UI Components

The deck viewer consists of multiple components:

Commander Card Display

  • Large card image for the commander
  • Partner commander toggle (if applicable)
  • Card details (name, mana cost, type line, rules text)
  • Color identity indicators

Deck Information

  • Deck name
  • Owner's name
  • Format legality
  • Deck description (optional)

Deck Statistics

  • Color identity
  • Card count
  • Average mana value
  • Card type distribution
  • Mana curve graph
  • Estimated power level

Action Buttons

  • View full decklist button (if shared by owner)
  • Request deck details button
  • Select deck button (for local player)

Privacy Controls

Players have control over how much of their deck information is shared:

#![allow(unused)]
fn main() {
/// Deck privacy settings
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum DeckPrivacyLevel {
    /// Only commander is visible
    CommanderOnly,
    /// Commander and basic stats are visible
    BasicStats,
    /// Full decklist is shared
    FullDecklist,
    /// Custom setting with specific visibility options
    Custom(DeckPrivacyOptions),
}

/// Detailed privacy options for decks
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct DeckPrivacyOptions {
    /// Whether to show the commander
    pub show_commander: bool,
    /// Whether to show basic statistics
    pub show_stats: bool,
    /// Whether to show card type breakdown
    pub show_card_types: bool,
    /// Whether to show mana curve
    pub show_mana_curve: bool,
    /// Maximum number of cards to reveal (0 = none)
    pub cards_to_reveal: usize,
    /// Categories of cards to reveal
    pub reveal_categories: Vec<CardCategory>,
}
}

Players set their privacy preferences when readying up with a deck:

#![allow(unused)]
fn main() {
/// System to handle deck privacy settings
fn handle_deck_privacy_settings(
    mut interaction_query: Query<
        (&Interaction, &DeckPrivacyOption),
        (Changed<Interaction>, With<Button>),
    >,
    mut selected_deck: ResMut<SelectedDeck>,
) {
    for (interaction, privacy_option) in &mut interaction_query {
        if *interaction == Interaction::Pressed {
            selected_deck.privacy_level = privacy_option.level.clone();
        }
    }
}
}

Commander Preview

The commander preview is the centerpiece of the deck viewer:

#![allow(unused)]
fn main() {
/// Set up the commander preview display
fn setup_commander_preview(
    parent: &mut ChildBuilder,
    asset_server: &Res<AssetServer>,
    commander_info: Option<&CommanderInfo>,
) {
    // Commander card container
    parent
        .spawn(Node {
            width: Val::Percent(100.0),
            height: Val::Px(300.0),
            justify_content: JustifyContent::Center,
            align_items: AlignItems::Center,
            margin: UiRect::bottom(Val::Px(15.0)),
            ..default()
        })
        .with_children(|commander_container| {
            if let Some(commander) = commander_info {
                // Commander card image
                if let Some(ref image_uri) = commander.image_uri {
                    commander_container.spawn((
                        Image {
                            texture: asset_server.load(image_uri),
                            ..default()
                        },
                        Node {
                            width: Val::Px(220.0),
                            height: Val::Px(300.0),
                            ..default()
                        },
                        CommanderCard,
                    ));
                } else {
                    // Fallback if no image is available
                    commander_container
                        .spawn((
                            Node {
                                width: Val::Px(220.0),
                                height: Val::Px(300.0),
                                flex_direction: FlexDirection::Column,
                                padding: UiRect::all(Val::Px(10.0)),
                                ..default()
                            },
                            BackgroundColor(Color::rgb(0.1, 0.1, 0.1)),
                            BorderColor(Color::rgb(0.3, 0.3, 0.3)),
                            Outline::new(Val::Px(2.0)),
                            CommanderCard,
                        ))
                        .with_children(|card| {
                            // Card name and mana cost
                            card.spawn(Node {
                                width: Val::Percent(100.0),
                                height: Val::Auto,
                                justify_content: JustifyContent::SpaceBetween,
                                align_items: AlignItems::Center,
                                margin: UiRect::bottom(Val::Px(10.0)),
                                ..default()
                            }).with_children(|header| {
                                // Card name
                                header.spawn(Text2d {
                                    text: commander.name.clone(),
                                    font: asset_server.load("fonts/FiraSans-Bold.ttf"),
                                    font_size: 18.0,
                                    color: Color::WHITE,
                                });
                                
                                // Mana cost
                                header.spawn(Text2d {
                                    text: commander.mana_cost.clone(),
                                    font: asset_server.load("fonts/FiraSans-Bold.ttf"),
                                    font_size: 18.0,
                                    color: Color::WHITE,
                                });
                            });
                            
                            // Type line
                            card.spawn(Text2d {
                                text: commander.type_line.clone(),
                                font: asset_server.load("fonts/FiraSans-Italic.ttf"),
                                font_size: 14.0,
                                color: Color::rgb(0.9, 0.9, 0.9),
                            });
                            
                            // Rules text
                            card.spawn(Node {
                                width: Val::Percent(100.0),
                                height: Val::Percent(70.0),
                                margin: UiRect::top(Val::Px(10.0)),
                                ..default()
                            }).with_children(|text_box| {
                                text_box.spawn(Text2d {
                                    text: commander.text.clone(),
                                    font: asset_server.load("fonts/FiraSans-Regular.ttf"),
                                    font_size: 14.0,
                                    color: Color::WHITE,
                                });
                            });
                            
                            // Power/Toughness if applicable
                            if let Some(ref pt) = commander.power_toughness {
                                card.spawn(Node {
                                    width: Val::Percent(100.0),
                                    height: Val::Auto,
                                    justify_content: JustifyContent::FlexEnd,
                                    ..default()
                                }).with_children(|pt_container| {
                                    pt_container.spawn(Text2d {
                                        text: pt.clone(),
                                        font: asset_server.load("fonts/FiraSans-Bold.ttf"),
                                        font_size: 16.0,
                                        color: Color::WHITE,
                                    });
                                });
                            }
                        });
                }
            } else {
                // No commander selected yet
                commander_container
                    .spawn((
                        Node {
                            width: Val::Px(220.0),
                            height: Val::Px(300.0),
                            justify_content: JustifyContent::Center,
                            align_items: AlignItems::Center,
                            ..default()
                        },
                        BackgroundColor(Color::rgba(0.1, 0.1, 0.1, 0.5)),
                        BorderColor(Color::rgba(0.3, 0.3, 0.3, 0.5)),
                        Outline::new(Val::Px(1.0)),
                        EmptyCommanderCard,
                    ))
                    .with_children(|empty_card| {
                        empty_card.spawn(Text2d {
                            text: "No Commander Selected".into(),
                            font: asset_server.load("fonts/FiraSans-Italic.ttf"),
                            font_size: 16.0,
                            color: Color::rgba(0.7, 0.7, 0.7, 1.0),
                        });
                    });
            }
        });
}
}

Deck Statistics

The deck statistics section provides additional information:

#![allow(unused)]
fn main() {
/// Set up the deck statistics display
fn setup_deck_statistics(
    parent: &mut ChildBuilder,
    asset_server: &Res<AssetServer>,
    deck_info: Option<&DeckInfo>,
) {
    // Stats container
    parent
        .spawn(Node {
            width: Val::Percent(100.0),
            height: Val::Auto,
            flex_direction: FlexDirection::Column,
            padding: UiRect::all(Val::Px(10.0)),
            margin: UiRect::bottom(Val::Px(10.0)),
            ..default()
        })
        .with_children(|stats_container| {
            if let Some(deck) = deck_info {
                // Player and deck name
                stats_container.spawn(Text2d {
                    text: format!("Player: {}", deck.player_name),
                    font: asset_server.load("fonts/FiraSans-Bold.ttf"),
                    font_size: 16.0,
                    color: Color::WHITE,
                });
                
                stats_container.spawn(Text2d {
                    text: format!("Deck: \"{}\"", deck.name),
                    font: asset_server.load("fonts/FiraSans-Bold.ttf"),
                    font_size: 16.0,
                    color: Color::WHITE,
                });
                
                // Color identity
                stats_container
                    .spawn(Node {
                        width: Val::Percent(100.0),
                        height: Val::Auto,
                        flex_direction: FlexDirection::Row,
                        align_items: AlignItems::Center,
                        margin: UiRect::vertical(Val::Px(5.0)),
                        ..default()
                    })
                    .with_children(|color_row| {
                        color_row.spawn(Text2d {
                            text: "Color Identity: ".into(),
                            font: asset_server.load("fonts/FiraSans-Regular.ttf"),
                            font_size: 14.0,
                            color: Color::WHITE,
                        });
                        
                        // Display color pips
                        for color in &deck.colors {
                            color_row.spawn((
                                Node {
                                    width: Val::Px(20.0),
                                    height: Val::Px(20.0),
                                    margin: UiRect::left(Val::Px(2.0)),
                                    ..default()
                                },
                                BackgroundColor(get_color_identity_color(color)),
                                BorderColor(Color::WHITE),
                                Outline::new(Val::Px(1.0)),
                                ColorIdentityPip(color.clone()),
                            ));
                        }
                    });
                
                // Other statistics
                stats_container.spawn(Text2d {
                    text: format!("Average CMC: {:.1}", deck.avg_mana_value),
                    font: asset_server.load("fonts/FiraSans-Regular.ttf"),
                    font_size: 14.0,
                    color: Color::WHITE,
                });
                
                stats_container.spawn(Text2d {
                    text: format!("Power Level: {}/10", deck.power_level),
                    font: asset_server.load("fonts/FiraSans-Regular.ttf"),
                    font_size: 14.0,
                    color: get_power_level_color(deck.power_level),
                });
                
                // View full decklist button
                if deck.share_decklist {
                    stats_container
                        .spawn((
                            Button,
                            Node {
                                width: Val::Percent(100.0),
                                height: Val::Px(40.0),
                                justify_content: JustifyContent::Center,
                                align_items: AlignItems::Center,
                                margin: UiRect::top(Val::Px(10.0)),
                                ..default()
                            },
                            BackgroundColor(Color::rgba(0.2, 0.4, 0.8, 0.7)),
                            ViewDecklistButton(deck.player_id.clone()),
                        ))
                        .with_children(|button| {
                            button.spawn(Text2d {
                                text: format!("View Full Decklist ({}/{})", deck.card_count, deck.card_count),
                                font: asset_server.load("fonts/FiraSans-Regular.ttf"),
                                font_size: 14.0,
                                color: Color::WHITE,
                            });
                        });
                } else {
                    // Decklist not shared
                    stats_container
                        .spawn((
                            Node {
                                width: Val::Percent(100.0),
                                height: Val::Px(40.0),
                                justify_content: JustifyContent::Center,
                                align_items: AlignItems::Center,
                                margin: UiRect::top(Val::Px(10.0)),
                                ..default()
                            },
                            BackgroundColor(Color::rgba(0.2, 0.2, 0.2, 0.7)),
                        ))
                        .with_children(|container| {
                            container.spawn(Text2d {
                                text: "Decklist Not Shared".into(),
                                font: asset_server.load("fonts/FiraSans-Italic.ttf"),
                                font_size: 14.0,
                                color: Color::rgba(0.7, 0.7, 0.7, 1.0),
                            });
                        });
                }
            } else {
                // No deck selected
                stats_container.spawn(Text2d {
                    text: "No Deck Selected".into(),
                    font: asset_server.load("fonts/FiraSans-Italic.ttf"),
                    font_size: 16.0,
                    color: Color::rgba(0.7, 0.7, 0.7, 1.0),
                });
            }
        });
}
}

Implementation

The deck viewer is implemented using Bevy's ECS architecture:

Overall Deck Viewer Setup

#![allow(unused)]
fn main() {
/// Set up the deck viewer panel UI
pub fn setup_deck_viewer_panel(
    parent: &mut ChildBuilder,
    asset_server: &Res<AssetServer>,
) {
    // Deck viewer container
    parent
        .spawn((
            Node {
                width: Val::Percent(30.0),
                height: Val::Percent(100.0),
                flex_direction: FlexDirection::Column,
                ..default()
            },
            DeckViewerUI,
        ))
        .with_children(|deck_viewer| {
            // Header
            deck_viewer
                .spawn(Node {
                    width: Val::Percent(100.0),
                    height: Val::Px(30.0),
                    justify_content: JustifyContent::Center,
                    align_items: AlignItems::Center,
                    margin: UiRect::bottom(Val::Px(10.0)),
                    ..default()
                })
                .with_children(|header| {
                    header.spawn(Text2d {
                        text: "DECK VIEWER".into(),
                        font: asset_server.load("fonts/FiraSans-Bold.ttf"),
                        font_size: 18.0,
                        color: Color::WHITE,
                    });
                });
                
            // Deck selection tabs
            setup_deck_selection_tabs(deck_viewer, asset_server);
            
            // Commander preview area
            setup_commander_preview(deck_viewer, asset_server, None);
            
            // Deck statistics area
            setup_deck_statistics(deck_viewer, asset_server, None);
        });
}

/// Set up tabs to select which player's deck to view
fn setup_deck_selection_tabs(
    parent: &mut ChildBuilder,
    asset_server: &Res<AssetServer>,
) {
    parent
        .spawn((
            Node {
                width: Val::Percent(100.0),
                height: Val::Px(40.0),
                flex_direction: FlexDirection::Row,
                margin: UiRect::bottom(Val::Px(10.0)),
                ..default()
            },
            DeckSelectionTabs,
        ));
    // Tabs will be populated dynamically based on players in lobby
}
}

Deck Selection

The viewer supports selecting whose deck to view:

#![allow(unused)]
fn main() {
/// System to update deck selection tabs
fn update_deck_selection_tabs(
    mut commands: Commands,
    asset_server: Res<AssetServer>,
    lobby_info: Res<CurrentLobbyInfo>,
    tabs_query: Query<Entity, With<DeckSelectionTabs>>,
    children_query: Query<Entity>,
    selected_player_deck: Res<SelectedPlayerDeck>,
) {
    if lobby_info.is_changed() || selected_player_deck.is_changed() {
        // Get the tabs container
        if let Ok(tabs_entity) = tabs_query.get_single() {
            // Clear existing tabs
            let children = children_query.iter_descendants(tabs_entity);
            for child in children {
                commands.entity(child).despawn_recursive();
            }
            
            // Add tabs for each player with a selected deck
            commands.entity(tabs_entity).with_children(|tabs| {
                for player in lobby_info.players.values() {
                    if player.status == PlayerLobbyState::Ready {
                        let is_selected = selected_player_deck.player_id == Some(player.id.clone());
                        
                        tabs.spawn((
                            Button,
                            Node {
                                width: Val::Auto,
                                min_width: Val::Px(100.0),
                                height: Val::Percent(100.0),
                                justify_content: JustifyContent::Center,
                                align_items: AlignItems::Center,
                                padding: UiRect::horizontal(Val::Px(10.0)),
                                margin: UiRect::right(Val::Px(5.0)),
                                ..default()
                            },
                            BackgroundColor(if is_selected {
                                Color::rgba(0.3, 0.5, 0.8, 0.7)
                            } else {
                                Color::rgba(0.2, 0.2, 0.2, 0.7)
                            }),
                            DeckSelectionTab(player.id.clone()),
                        )).with_children(|tab| {
                            tab.spawn(Text2d {
                                text: player.name.clone(),
                                font: asset_server.load("fonts/FiraSans-Regular.ttf"),
                                font_size: 14.0,
                                color: Color::WHITE,
                            });
                        });
                    }
                }
            });
        }
    }
}

/// System to handle deck tab selection
fn handle_deck_tab_selection(
    mut interaction_query: Query<
        (&Interaction, &DeckSelectionTab),
        (Changed<Interaction>, With<Button>),
    >,
    mut selected_player_deck: ResMut<SelectedPlayerDeck>,
) {
    for (interaction, tab) in &mut interaction_query {
        if *interaction == Interaction::Pressed {
            selected_player_deck.player_id = Some(tab.0.clone());
        }
    }
}
}

Full Decklist Viewer

When a player clicks to view a full decklist (if shared):

#![allow(unused)]
fn main() {
/// System to handle decklist view button
fn handle_view_decklist_button(
    mut interaction_query: Query<
        (&Interaction, &ViewDecklistButton),
        (Changed<Interaction>, With<Button>),
    >,
    mut deck_view_events: EventWriter<ViewDecklistEvent>,
) {
    for (interaction, button) in &mut interaction_query {
        if *interaction == Interaction::Pressed {
            deck_view_events.send(ViewDecklistEvent(button.0.clone()));
        }
    }
}

/// System to show full decklist popup
fn show_decklist_popup(
    mut commands: Commands,
    mut deck_view_events: EventReader<ViewDecklistEvent>,
    asset_server: Res<AssetServer>,
    lobby_connection: Res<LobbyConnection>,
) {
    for event in deck_view_events.read() {
        // Request decklist from server
        lobby_connection.request_decklist(event.0.clone());
        
        // Show loading popup
        commands
            .spawn((
                Node {
                    width: Val::Percent(80.0),
                    height: Val::Percent(80.0),
                    position_type: PositionType::Absolute,
                    position: UiRect {
                        left: Val::Percent(10.0),
                        top: Val::Percent(10.0),
                        ..default()
                    },
                    flex_direction: FlexDirection::Column,
                    padding: UiRect::all(Val::Px(20.0)),
                    ..default()
                },
                BackgroundColor(Color::rgba(0.1, 0.1, 0.1, 0.95)),
                BorderColor(Color::rgba(0.3, 0.3, 0.3, 1.0)),
                Outline::new(Val::Px(2.0)),
                DecklistPopup,
            ))
            .with_children(|popup| {
                // Header
                popup
                    .spawn(Node {
                        width: Val::Percent(100.0),
                        height: Val::Px(40.0),
                        justify_content: JustifyContent::SpaceBetween,
                        align_items: AlignItems::Center,
                        margin: UiRect::bottom(Val::Px(20.0)),
                        ..default()
                    })
                    .with_children(|header| {
                        // Title
                        header.spawn(Text2d {
                            text: "Loading Decklist...".into(),
                            font: asset_server.load("fonts/FiraSans-Bold.ttf"),
                            font_size: 24.0,
                            color: Color::WHITE,
                        });
                        
                        // Close button
                        header
                            .spawn((
                                Button,
                                Node {
                                    width: Val::Px(30.0),
                                    height: Val::Px(30.0),
                                    justify_content: JustifyContent::Center,
                                    align_items: AlignItems::Center,
                                    ..default()
                                },
                                BackgroundColor(Color::rgba(0.7, 0.3, 0.3, 0.7)),
                                CloseDecklistPopupButton,
                            ))
                            .with_children(|button| {
                                button.spawn(Text2d {
                                    text: "X".into(),
                                    font: asset_server.load("fonts/FiraSans-Bold.ttf"),
                                    font_size: 18.0,
                                    color: Color::WHITE,
                                });
                            });
                    });
                
                // Loading indicator
                popup.spawn((
                    Node {
                        width: Val::Percent(100.0),
                        height: Val::Percent(100.0),
                        justify_content: JustifyContent::Center,
                        align_items: AlignItems::Center,
                        ..default()
                    },
                    DecklistContentContainer,
                )).with_children(|container| {
                    container.spawn(Text2d {
                        text: "Loading...".into(),
                        font: asset_server.load("fonts/FiraSans-Italic.ttf"),
                        font_size: 18.0,
                        color: Color::rgba(0.7, 0.7, 0.7, 1.0),
                    });
                });
            });
    }
}
}

The deck viewer provides a crucial function in multiplayer Commander games by allowing players to gauge deck compatibility before starting a game, helping to ensure a balanced and enjoyable experience for all participants.

Lobby UI System

This section covers the user interface components of the game lobby, providing players with visual access to matchmaking, game creation, and pre-game setup.

Overview

The lobby UI system serves as the player's primary interface for finding and joining games, managing their profile, and preparing for gameplay.

Components

Overview

General overview of the lobby UI system, including:

  • Main lobby screen layout
  • Navigation structure
  • Visual design principles
  • Accessibility considerations
  • Responsive design for different screen sizes

Detail View

Detailed information about specific UI components, including:

  • Game room cards and information display
  • Player profile and statistics panels
  • Deck selection interface
  • Game creation wizard
  • Settings and preferences panels
  • Friend list and social features

Features

  • Intuitive game browsing and filtering
  • Quick join and matchmaking options
  • Game room creation with customizable settings
  • Player profile and statistics display
  • Friend list and social interaction
  • Deck selection and validation
  • Pre-game setup and configuration

Integration

The lobby UI integrates with:

Multiplayer Lobby Detail UI

This document outlines the user interface for the lobby detail screen in the multiplayer system. This screen is displayed after a player joins a specific lobby and is where players prepare for the game, interact with each other, and manage their participation.

Table of Contents

  1. UI Layout
  2. Components
  3. Player Management
  4. Player Actions
  5. Host Controls
  6. Handling Player Departures
  7. Implementation

UI Layout

The lobby detail screen is divided into three main panels:

  1. Left Panel: Lobby information and player list
  2. Center Panel: Chat system
  3. Right Panel: Deck viewer
┌───────────────────────────────────────────────────────┐
│ Lobby Name                  Host: PlayerName          │
├───────────────┬───────────────────┬───────────────────┤
│               │                   │                   │
│ PLAYERS       │                   │ DECK VIEWER       │
│               │                   │                   │
│ ○ Player1     │                   │ ┌─────────────┐   │
│   (Host)      │                   │ │             │   │
│               │                   │ │ Commander   │   │
│ ○ Player2     │     CHAT          │ │ Card        │   │
│   [Ready]     │                   │ │             │   │
│               │                   │ └─────────────┘   │
│ ○ Player3     │                   │                   │
│   [Selecting] │                   │ Deck Statistics   │
│               │                   │                   │
│ ○ Player4     │                   │                   │
│               │                   │                   │
├───────────────┴───────────────────┴───────────────────┤
│ [Leave Lobby]    [Select Deck]       [Ready Up]       │
└───────────────────────────────────────────────────────┘

Components

Lobby Header

The header displays essential information about the lobby:

  • Lobby name
  • Host name
  • Game format (Standard Commander, cEDH, etc.)
  • Player count (current/maximum)
  • Password protection indicator

Player List

The player list shows all players in the lobby with their current status:

  • Player name
  • Ready status
  • Selected deck (if ready)
  • Host indicator
  • Color identity of selected commander (if ready)

Ready Controls

Players can indicate their readiness through:

  • Deck selection button
  • Ready/Unready toggle
  • Current status indicator

Player Management

The lobby implements a robust player management system to handle various player actions and states.

Player States

Players can be in various states while in the lobby:

#![allow(unused)]
fn main() {
/// Player state in the lobby
#[derive(Component, Clone, Debug, PartialEq, Eq)]
pub enum PlayerLobbyState {
    /// Just joined, not ready
    Joined,
    /// Selecting a deck
    SelectingDeck,
    /// Ready with deck selected
    Ready,
    /// Temporarily disconnected (can reconnect)
    Disconnected,
    /// In the process of joining
    Joining,
}
}

Player Actions

Players can perform several actions in the lobby:

Selecting a Deck

Players need to select a deck before they can ready up:

#![allow(unused)]
fn main() {
fn handle_deck_selection(
    mut interaction_query: Query<&Interaction, (Changed<Interaction>, With<DeckSelectButton>)>,
    mut commands: Commands,
    mut next_state: ResMut<NextState<DeckSelectionState>>,
) {
    for interaction in &mut interaction_query {
        if *interaction == Interaction::Pressed {
            // Transition to deck selection screen
            next_state.set(DeckSelectionState::Selecting);
        }
    }
}
}

Ready Status

Players indicate they are ready to play:

#![allow(unused)]
fn main() {
fn handle_ready_button(
    mut interaction_query: Query<&Interaction, (Changed<Interaction>, With<ReadyButton>)>,
    mut player_query: Query<&mut PlayerLobbyState, With<LocalPlayer>>,
    mut lobby_connection: ResMut<LobbyConnection>,
) {
    for interaction in &mut interaction_query {
        if *interaction == Interaction::Pressed {
            // Toggle ready status
            if let Ok(mut state) = player_query.get_single_mut() {
                match *state {
                    PlayerLobbyState::Ready => {
                        *state = PlayerLobbyState::Joined;
                        lobby_connection.send_status_update(PlayerLobbyState::Joined);
                    }
                    _ => {
                        if lobby_connection.has_deck_selected() {
                            *state = PlayerLobbyState::Ready;
                            lobby_connection.send_status_update(PlayerLobbyState::Ready);
                        }
                    }
                }
            }
        }
    }
}
}

Leaving a Lobby

Players can voluntarily leave a lobby:

#![allow(unused)]
fn main() {
fn handle_leave_button(
    mut interaction_query: Query<&Interaction, (Changed<Interaction>, With<LeaveButton>)>,
    mut lobby_connection: ResMut<LobbyConnection>,
    mut next_state: ResMut<NextState<GameMenuState>>,
) {
    for interaction in &mut interaction_query {
        if *interaction == Interaction::Pressed {
            // Send leave notification to server
            lobby_connection.leave_lobby();
            
            // Return to lobby browser
            next_state.set(GameMenuState::MultiplayerBrowser);
        }
    }
}
}

Host Controls

The host player has additional controls for managing the lobby:

Start Game

The host can start the game when all players are ready:

#![allow(unused)]
fn main() {
fn handle_start_game_button(
    mut interaction_query: Query<&Interaction, (Changed<Interaction>, With<StartGameButton>)>,
    mut lobby_connection: ResMut<LobbyConnection>,
    player_query: Query<&PlayerLobbyState>,
) {
    for interaction in &mut interaction_query {
        if *interaction == Interaction::Pressed {
            // Check if all players are ready
            let all_ready = player_query
                .iter()
                .all(|state| *state == PlayerLobbyState::Ready);
                
            if all_ready {
                lobby_connection.start_game();
            }
        }
    }
}
}

Kick Player

The host can remove players from the lobby:

#![allow(unused)]
fn main() {
fn handle_kick_button(
    mut interaction_query: Query<(&Interaction, &KickButtonTarget), (Changed<Interaction>, With<KickButton>)>,
    mut lobby_connection: ResMut<LobbyConnection>,
) {
    for (interaction, target) in &mut interaction_query {
        if *interaction == Interaction::Pressed {
            // Send kick request to server
            lobby_connection.kick_player(target.player_id.clone());
        }
    }
}
}

Handling Player Departures

The system handles various scenarios for player departures from the lobby:

Voluntary Departure

When a player chooses to leave a lobby:

  1. The player initiates departure through the Leave button
  2. A leave message is sent to the server
  3. The server broadcasts the player's departure to all other players
  4. The player's UI transitions to the lobby browser
  5. Other players receive a notification of the departure
#![allow(unused)]
fn main() {
/// Message sent when a player leaves a lobby
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct LeaveLobbyMessage {
    /// ID of the lobby being left
    pub lobby_id: String,
    /// Reason for leaving
    pub reason: LeaveLobbyReason,
}

/// Reasons a player might leave a lobby
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
pub enum LeaveLobbyReason {
    /// Player chose to leave
    Voluntary,
    /// Player was kicked by the host
    Kicked,
    /// Player disconnected unexpectedly
    Disconnected,
    /// Player was idle for too long
    Timeout,
}
}

Being Kicked

When a host kicks a player:

  1. The host selects a player and clicks the Kick button
  2. A kick message is sent to the server
  3. The server validates the request (ensures sender is host)
  4. The server sends a departure notification to the kicked player
  5. The kicked player's UI transitions to the lobby browser with a message
  6. Other players receive a notification that the player was kicked
#![allow(unused)]
fn main() {
/// System to handle being kicked from a lobby
fn handle_being_kicked(
    mut kick_events: EventReader<KickedFromLobbyEvent>,
    mut commands: Commands,
    mut next_state: ResMut<NextState<GameMenuState>>,
) {
    for event in kick_events.read() {
        // Display kicked message
        commands.spawn(KickedNotificationUI {
            reason: event.reason.clone(),
        });
        
        // Return to lobby browser
        next_state.set(GameMenuState::MultiplayerBrowser);
    }
}
}

Disconnections

The system also handles unexpected disconnections:

  1. When a player disconnects, the server detects the dropped connection
  2. The server broadcasts a player disconnection to all remaining players
  3. The server keeps the player's slot reserved for a period of time
  4. If the player reconnects within the time window, they rejoin seamlessly
  5. If the reconnection window expires, the slot is freed and other players are notified
#![allow(unused)]
fn main() {
/// System to handle player disconnections
fn handle_player_disconnection(
    mut disconnection_events: EventReader<PlayerDisconnectedEvent>,
    mut player_query: Query<(&mut PlayerLobbyState, &PlayerName)>,
    mut lobby_state: ResMut<LobbyState>,
) {
    for event in disconnection_events.read() {
        // Update the disconnected player's state
        for (mut state, name) in &mut player_query {
            if name.0 == event.player_name {
                *state = PlayerLobbyState::Disconnected;
                
                // Start reconnection timer
                lobby_state.disconnection_timers.insert(
                    event.player_name.clone(),
                    ReconnectionTimer {
                        time_remaining: RECONNECTION_WINDOW,
                        player_id: event.player_id.clone(),
                    }
                );
                
                break;
            }
        }
    }
}
}

Host Migration

If the host leaves or disconnects, the system can migrate host privileges to another player:

  1. When the host leaves, the server selects the next player (typically by join time)
  2. The server broadcasts a host migration message to all players
  3. The new host receives additional UI controls
  4. All players are notified of the host change
#![allow(unused)]
fn main() {
/// Message for host migration
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct HostMigrationMessage {
    /// ID of the new host
    pub new_host_id: String,
    /// Name of the new host
    pub new_host_name: String,
}

/// System to handle host migration
fn handle_host_migration(
    mut migration_events: EventReader<HostMigrationEvent>,
    mut player_query: Query<(&mut PlayerLobbyState, &PlayerName, &mut PlayerUI)>,
    mut lobby_state: ResMut<LobbyState>,
) {
    for event in migration_events.read() {
        // Update host status
        lobby_state.host_id = event.new_host_id.clone();
        
        // Update UI to reflect new host
        for (state, name, mut ui) in &mut player_query {
            if name.0 == event.new_host_name {
                ui.show_host_controls();
            }
        }
    }
}
}

Implementation

The lobby detail UI is implemented using Bevy's UI system:

#![allow(unused)]
fn main() {
/// Set up the lobby detail screen
pub fn setup_lobby_detail(
    mut commands: Commands,
    asset_server: Res<AssetServer>,
    lobby_info: Res<CurrentLobbyInfo>,
) {
    // Main container
    commands
        .spawn((
            Node {
                width: Val::Percent(100.0),
                height: Val::Percent(100.0),
                flex_direction: FlexDirection::Column,
                ..default()
            },
            LobbyDetailUI,
        ))
        .with_children(|parent| {
            // Header
            setup_lobby_header(parent, &asset_server, &lobby_info);
            
            // Main content area
            parent
                .spawn(Node {
                    width: Val::Percent(100.0),
                    height: Val::Percent(85.0),
                    flex_direction: FlexDirection::Row,
                    ..default()
                })
                .with_children(|content| {
                    // Left panel (player list)
                    setup_player_list_panel(content, &asset_server, &lobby_info);
                    
                    // Center panel (chat)
                    setup_chat_panel(content, &asset_server);
                    
                    // Right panel (deck viewer)
                    setup_deck_viewer_panel(content, &asset_server);
                });
                
            // Footer with action buttons
            setup_action_buttons(parent, &asset_server, &lobby_info);
        });
}
}

This UI adapts based on the player's role (host or regular player) and provides appropriate controls for each state.

Multiplayer Lobby UI System Overview

This document provides a high-level overview of the multiplayer lobby user interface system for Rummage's Commander format game. For more detailed information on specific components, please see the related documentation files.

Table of Contents

  1. UI Architecture Overview
  2. UI Flow
  3. Screen Components
  4. Integration with Game States
  5. Related Documentation

UI Architecture Overview

The multiplayer lobby system uses Bevy's UI components, with a focus on clean, responsive design that works well across different screen sizes. The UI is built using these key Bevy components:

  • Node for layout containers
  • Button for interactive elements
  • Text2d for text display
  • Image for graphics and icons

The UI follows a component-based architecture where different parts of the interface are organized hierarchically and can be created, updated, or removed independently.

UI Flow

The user flow through the multiplayer lobby system follows this sequence:

  1. Main Menu: Player clicks the "Multiplayer" button in the main menu
  2. Server Connection: Player selects a server or enters a direct IP address
  3. Lobby Browser: Player views a list of available game lobbies
  4. Lobby Detail: Player joins a lobby and prepares for the game
  5. Game Launch: When all players are ready, the host launches the game

Each of these screens has its own set of UI components and systems to handle user interaction.

┌─────────────┐
│  Main Menu  │
└──────┬──────┘
       │
       ▼
┌─────────────────┐
│ Server Connection│
└────────┬────────┘
         │
         ▼
┌─────────────────┐
│  Lobby Browser  │◄────┐
└────────┬────────┘     │
         │              │
         ▼              │
┌─────────────────┐     │
│  Lobby Detail   │─────┘
└────────┬────────┘
         │
         ▼
┌─────────────────┐
│   Game Launch   │
└─────────────────┘

Screen Components

Each screen in the lobby system consists of multiple UI components:

1. Server Connection Screen

  • Server list with ping indicators
  • Direct IP connection field
  • Connect button
  • Back button

2. Lobby Browser Screen

  • Lobby list with game information
  • Filter and sort controls
  • Create lobby button
  • Refresh button
  • Lobby details panel
  • Join lobby button

3. Lobby Detail Screen

  • Lobby information header
  • Player list with status indicators
  • Chat panel
  • Deck selection controls
  • Ready up button
  • Deck viewing section

4. Game Launch Transition

  • Loading screen
  • Connection status indicators
  • Game initialization progress

Integration with Game States

The lobby UI system integrates with Bevy's state management system:

#![allow(unused)]
fn main() {
/// Game states extended to include multiplayer states
#[derive(States, Debug, Clone, Copy, Eq, PartialEq, Hash, Default)]
pub enum GameMenuState {
    /// Initial state, showing the main menu
    #[default]
    MainMenu,
    /// Multiplayer lobby browser
    MultiplayerBrowser,
    /// Inside a specific lobby
    MultiplayerLobby,
    /// Transitional state for loading game assets
    Loading,
    /// Active gameplay state
    InGame,
    /// Game is paused, showing pause menu
    PausedGame,
}
}

State transitions are handled by systems that respond to user interactions and network events:

#![allow(unused)]
fn main() {
fn handle_multiplayer_button(
    mut interaction_query: Query<
        (&Interaction, &MenuButtonAction, &mut BackgroundColor),
        (Changed<Interaction>, With<Button>),
    >,
    mut next_state: ResMut<NextState<GameMenuState>>,
) {
    for (interaction, action, mut color) in &mut interaction_query {
        if *interaction == Interaction::Pressed && *action == MenuButtonAction::Multiplayer {
            next_state.set(GameMenuState::MultiplayerBrowser);
        }
    }
}
}

For more detailed information about specific aspects of the lobby UI system, please refer to these documents:

Gameplay Networking Documentation

This section covers the networking aspects specific to gameplay in the MTG Commander game engine. While the lobby system handles pre-game setup, the gameplay networking components manage the actual game session, including state synchronization, player actions, and handling departures.

Overview

The gameplay networking implementation focuses on several key areas:

  1. State Management: Maintaining and synchronizing the game state across all clients
  2. Action Processing: Handling player actions and their effects on the game state
  3. Synchronization: Ensuring all clients have a consistent view of the game
  4. Departure Handling: Managing player disconnections and reconnections

Gameplay Components

State Management

The State Management system handles the representation and replication of game state:

  • Core game state structure and components
  • State replication with bevy_replicon
  • Hidden information management
  • State consistency and verification
  • Rollback and Recovery for handling network disruptions

Synchronization

The Synchronization system ensures all clients maintain a consistent view of the game:

  • Server-authoritative model
  • Command-based synchronization
  • Incremental state updates
  • Tick-based processing
  • Handling network issues

Departure Handling

The Departure Handling system manages player disconnections and reconnections:

  • Detecting disconnections
  • Preserving game state for disconnected players
  • Handling reconnections
  • Game continuation policies
  • Timeout and abandonment handling

Implementation Principles

Our gameplay networking implementation follows these core principles:

  1. Server Authority: The server is the single source of truth for game state
  2. Minimal Network Usage: Only necessary information is transmitted
  3. Resilience: The system can handle network disruptions gracefully through deterministic rollbacks
  4. Security: Hidden information remains protected
  5. Fairness: All players have equal opportunity regardless of network conditions
  6. Determinism: Game actions produce identical results when replayed with the same RNG state

Integration with Other Systems

The gameplay networking components integrate with several other systems:

  • Lobby System: For transitioning from lobby to game
  • Security: For protecting hidden information and preventing cheating
  • Testing: For validating network behavior and performance

Future Enhancements

Planned gameplay networking enhancements include:

  • Spectator Mode: Allow non-players to watch games in progress
  • Replay System: Record and replay games for analysis
  • Enhanced Reconnection: More sophisticated state recovery for long disconnections
  • Optimized Synchronization: Improved performance for complex game states
  • Cross-Platform Play: Ensure consistent experience across different platforms

This documentation will evolve as the gameplay networking implementation progresses.

Game State Management in MTG Commander

This document outlines the approach to managing game state in the MTG Commander game engine's multiplayer implementation.

Table of Contents

  1. Overview
  2. Game State Components
  3. Implementation Approach
  4. State Snapshots
  5. State Synchronization
  6. Deterministic State Updates
  7. Hidden Information
  8. Rollbacks and Recovery

Overview

Proper game state management is critical for a multiplayer card game like Magic: The Gathering. The game state includes all information about the current game, including cards in various zones, player life totals, turn structure, and active effects. In a networked environment, this state must be synchronized across all clients while maintaining security and performance.

Game State Components

The game state in MTG Commander consists of several key components:

  1. Zones: Battlefield, hands, libraries, graveyards, exile, stack, and command zone
  2. Player Information: Life totals, mana pools, commander damage, etc.
  3. Turn Structure: Current phase, active player, priority player
  4. Effects: Ongoing effects, delayed triggers, replacement effects
  5. Game Metadata: Game ID, start time, game mode, etc.

Implementation Approach

Core Game State Structure

The game state is implemented as a collection of ECS components and resources:

#![allow(unused)]
fn main() {
// Core game state resource
#[derive(Resource)]
pub struct GameState {
    pub game_id: Uuid,
    pub start_time: DateTime<Utc>,
    pub game_mode: GameMode,
    pub turn_number: u32,
    pub current_phase: Phase,
    pub active_player_id: PlayerId,
    pub priority_player_id: Option<PlayerId>,
    pub stack: Vec<StackItem>,
}

// Player component
#[derive(Component)]
pub struct Player {
    pub id: PlayerId,
    pub client_id: ClientId,
    pub life_total: i32,
    pub mana_pool: ManaPool,
    pub commander_damage: HashMap<PlayerId, i32>,
}

// Zone components
#[derive(Component)]
pub struct Hand {
    pub cards: Vec<CardId>,
}

#[derive(Component)]
pub struct Library {
    pub cards: Vec<CardId>,
    pub top_revealed: bool,
}

#[derive(Component)]
pub struct Graveyard {
    pub cards: Vec<CardId>,
}

#[derive(Component)]
pub struct CommandZone {
    pub cards: Vec<CardId>,
}

// Battlefield is a shared resource
#[derive(Resource)]
pub struct Battlefield {
    pub permanents: Vec<Entity>,
}

// Card component
#[derive(Component)]
pub struct Card {
    pub id: CardId,
    pub name: String,
    pub card_type: CardType,
    pub owner_id: PlayerId,
    pub controller_id: PlayerId,
    // Other card properties...
}
}

State Replication with bevy_replicon

The game state is replicated using bevy_replicon, with careful control over what information is sent to each client:

#![allow(unused)]
fn main() {
// Register components for replication
fn register_replication(app: &mut App) {
    app.register_component_replication::<Player>()
        .register_component_replication::<Card>()
        // Only replicate public zone information
        .register_component_replication::<Graveyard>()
        .register_component_replication::<CommandZone>()
        // Register resources
        .register_resource_replication::<GameState>()
        .register_resource_replication::<Battlefield>();
        
    // Hand and Library require special handling for hidden information
    app.register_component_replication_with::<Hand>(
        RuleFns {
            serialize: |hand, ctx| {
                // Only send full hand to the owner
                if ctx.client_id == ctx.client_entity_map.get_client_id(hand.owner_entity) {
                    bincode::serialize(hand).ok()
                } else {
                    // Send only card count to other players
                    bincode::serialize(&HandInfo { card_count: hand.cards.len() }).ok()
                }
            },
            deserialize: |bytes, ctx| {
                // Handle deserialization based on what was sent
                // ...
            },
        }
    );
}
}

State Synchronization

The game state is synchronized across clients using a combination of techniques:

  1. Initial State: Full game state is sent when a client connects
  2. Incremental Updates: Only changes are sent during gameplay
  3. Command-Based: Player actions are sent as commands, not direct state changes
  4. Authoritative Server: Server validates all commands before applying them
#![allow(unused)]
fn main() {
// System to process player commands
fn process_player_commands(
    mut commands: Commands,
    mut command_events: EventReader<PlayerCommand>,
    game_state: Res<GameState>,
    players: Query<(Entity, &Player)>,
    // Other queries...
) {
    for command in command_events.read() {
        // Validate the command
        if !validate_command(command, &game_state, &players) {
            continue;
        }
        
        // Apply the command to the game state
        match command {
            PlayerCommand::PlayCard { player_id, card_id, targets } => {
                // Handle playing a card
                // ...
            },
            PlayerCommand::ActivateAbility { permanent_id, ability_index, targets } => {
                // Handle activating an ability
                // ...
            },
            // Other command types...
        }
    }
}
}

State Snapshots

In networked games, maintaining state consistency despite network disruptions is essential. Our MTG Commander implementation employs a comprehensive state rollback system for resilience:

  • Complete documentation: State Rollback and Recovery
  • Deterministic replay of game actions after network disruptions
  • State snapshots at critical game moments
  • RNG state preservation for consistent randomized outcomes
  • Client-side prediction for responsive gameplay

The rollback system integrates tightly with our deterministic RNG implementation to ensure that random events like shuffling and coin flips remain consistent across network boundaries, even during recovery from disruptions.

Deterministic State Updates

Maintaining state consistency is critical for a fair game experience. Several mechanisms ensure consistency:

  1. Sequence Numbers: Commands are processed in order
  2. State Verification: Periodic full state verification
  3. Reconciliation: Automatic correction of client-server state differences
  4. Rollback: Ability to roll back to a previous state if needed
#![allow(unused)]
fn main() {
// System to verify client state consistency
fn verify_client_state_consistency(
    mut server: ResMut<RepliconServer>,
    game_state: Res<GameState>,
    connected_clients: Res<ConnectedClients>,
) {
    // Periodically send state verification requests
    if game_state.turn_number % 5 == 0 && game_state.current_phase == Phase::Upkeep {
        for client_id in connected_clients.clients.keys() {
            // Generate state verification data
            let verification_data = generate_state_verification_data(&game_state);
            
            // Send verification request
            server.send_message(*client_id, StateVerificationRequest {
                turn: game_state.turn_number,
                verification_data,
            });
        }
    }
}
}

Hidden Information

In networked games, it's important to protect sensitive information from unauthorized access. MTG Commander implements several mechanisms to hide sensitive information:

  1. Encryption: All network communications are encrypted
  2. Access Control: Only authorized clients can access certain game state information
  3. Data Masking: Sensitive data is masked or obfuscated

Rollbacks and Recovery

In networked games, maintaining state consistency despite network disruptions is essential. Our MTG Commander implementation employs a comprehensive state rollback system for resilience:

  • Complete documentation: State Rollback and Recovery
  • Deterministic replay of game actions after network disruptions
  • State snapshots at critical game moments
  • RNG state preservation for consistent randomized outcomes
  • Client-side prediction for responsive gameplay

The rollback system integrates tightly with our deterministic RNG implementation to ensure that random events like shuffling and coin flips remain consistent across network boundaries, even during recovery from disruptions.

Testing Game State Management

Testing the game state management system involves:

  1. Unit Tests: Testing individual state components and transitions
  2. Integration Tests: Testing state synchronization across multiple clients
  3. Stress Tests: Testing state management under high load or poor network conditions

For detailed testing procedures, see the Integration Testing Strategy.

Future Enhancements

Planned improvements to game state management include:

  • Enhanced state compression for better network performance
  • More sophisticated state reconciliation algorithms
  • Support for game state snapshots and replays
  • Improved handling of complex card interactions

This documentation will be updated as game state management evolves.

State Rollback and Recovery

This document outlines the implementation of state rollback and recovery mechanisms in our MTG Commander game engine, addressing network disruptions and maintaining gameplay integrity despite unstable connections.

Table of Contents

  1. Overview
  2. Rollback Architecture
  3. State Snapshots
  4. Deterministic Replay
  5. RNG Synchronization for Rollbacks
  6. Client-Side Prediction
  7. Recovery Processes
  8. Implementation Example

Overview

In networked gameplay, unstable connections can lead to state inconsistencies between the server and clients. The state rollback system allows the game to:

  1. Detect state deviations
  2. Revert to a previous valid state
  3. Deterministically replay actions to catch up
  4. Resume normal play without disrupting the game flow

This approach is particularly important for turn-based games like MTG Commander where the integrity of game state is critical.

Rollback Architecture

Our rollback architecture follows these principles:

  1. Server Authority: The server maintains the authoritative game state
  2. State History: Both server and clients maintain a history of game states
  3. Deterministic Replay: Actions can be replayed deterministically to reconstruct state
  4. Input Buffering: Client inputs are buffered to handle resynchronization
  5. Minimal Disruption: Rollbacks should be as seamless as possible to players

Component Integration

#![allow(unused)]
fn main() {
// src/networking/state/rollback.rs
use bevy::prelude::*;
use bevy_prng::WyRand;
use bevy_rand::prelude::*;
use crate::networking::server::resources::GameServer;
use crate::game_engine::state::GameState;

/// Plugin for handling state rollbacks in networked games
pub struct StateRollbackPlugin;

impl Plugin for StateRollbackPlugin {
    fn build(&self, app: &mut App) {
        app.init_resource::<StateHistory>()
           .init_resource::<ClientInputBuffer>()
           .add_systems(Update, (
               create_state_snapshots,
               detect_state_deviations,
               handle_rollback_requests,
               apply_rollbacks,
           ));
    }
}
}

State Snapshots

The core of our rollback system is the ability to capture and restore game state snapshots:

#![allow(unused)]
fn main() {
/// Resource for tracking game state history
#[derive(Resource)]
pub struct StateHistory {
    /// Timestamped state snapshots
    pub snapshots: Vec<StateSnapshot>,
    /// Maximum number of snapshots to retain
    pub max_snapshots: usize,
    /// Time between state snapshots (in seconds)
    pub snapshot_interval: f32,
    /// Last snapshot time
    pub last_snapshot_time: f32,
}

impl Default for StateHistory {
    fn default() -> Self {
        Self {
            snapshots: Vec::new(),
            max_snapshots: 20, // Store up to 20 snapshots (~1 minute of gameplay at 3s intervals)
            snapshot_interval: 3.0, // Take a snapshot every 3 seconds
            last_snapshot_time: 0.0,
        }
    }
}

/// A complete snapshot of game state at a point in time
#[derive(Clone, Debug)]
pub struct StateSnapshot {
    /// Timestamp when this snapshot was created
    pub timestamp: f32,
    /// Unique sequence number
    pub sequence_id: u64,
    /// Serialized game state
    pub game_state: Vec<u8>,
    /// Serialized RNG state
    pub rng_state: Vec<u8>,
    /// Action sequence that led to this state
    pub action_sequence: Vec<ActionRecord>,
}
}

Creating Snapshots

#![allow(unused)]
fn main() {
/// System to periodically create game state snapshots
pub fn create_state_snapshots(
    mut state_history: ResMut<StateHistory>,
    game_state: Res<GameState>,
    global_rng: Res<GlobalEntropy<WyRand>>,
    time: Res<Time>,
    sequence_tracker: Res<ActionSequence>,
) {
    // Check if it's time for a new snapshot
    if time.elapsed_seconds() - state_history.last_snapshot_time >= state_history.snapshot_interval {
        // Create new snapshot
        let snapshot = StateSnapshot {
            timestamp: time.elapsed_seconds(),
            sequence_id: sequence_tracker.current_sequence_id,
            game_state: serialize_game_state(&game_state),
            rng_state: global_rng.try_serialize_state().unwrap_or_default(),
            action_sequence: sequence_tracker.recent_actions.clone(),
        };
        
        // Add to history
        state_history.snapshots.push(snapshot);
        state_history.last_snapshot_time = time.elapsed_seconds();
        
        // Trim history if needed
        if state_history.snapshots.len() > state_history.max_snapshots {
            state_history.snapshots.remove(0);
        }
    }
}
}

Deterministic Replay

To ensure consistent rollback behavior, all game actions must be deterministic and replayable:

#![allow(unused)]
fn main() {
/// Record of a game action for replay purposes
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ActionRecord {
    /// Unique sequence ID for this action
    pub sequence_id: u64,
    /// Player who initiated the action
    pub player_id: Entity,
    /// Timestamp when the action occurred
    pub timestamp: f32,
    /// The actual action
    pub action: GameAction,
}

/// System to replay actions after a rollback
pub fn replay_actions(
    mut commands: Commands,
    mut game_state: ResMut<GameState>,
    mut global_rng: ResMut<GlobalEntropy<WyRand>>,
    rollback_event: Res<RollbackEvent>,
    actions: Vec<ActionRecord>,
) {
    // Restore the game state and RNG to the rollback point
    deserialize_game_state(&mut game_state, &rollback_event.snapshot.game_state);
    global_rng.deserialize_state(&rollback_event.snapshot.rng_state).expect("Failed to restore RNG state");
    
    // Replay all actions that occurred after the rollback point
    for action in actions {
        // Process each action in sequence
        apply_action(&mut commands, &mut game_state, &mut global_rng, action);
    }
}
}

RNG Synchronization for Rollbacks

The RNG state is critical for deterministic rollbacks. We extend our existing RNG synchronization to support rollbacks:

#![allow(unused)]
fn main() {
/// Resource to track RNG snapshots for rollback
#[derive(Resource)]
pub struct RngSnapshotHistory {
    /// History of RNG states indexed by sequence ID
    pub snapshots: HashMap<u64, Vec<u8>>,
    /// Maximum number of RNG snapshots to keep
    pub max_snapshots: usize,
}

impl Default for RngSnapshotHistory {
    fn default() -> Self {
        Self {
            snapshots: HashMap::new(),
            max_snapshots: 100,
        }
    }
}

/// System to capture RNG state before randomized actions
pub fn capture_rng_before_randomized_action(
    sequence_tracker: Res<ActionSequence>,
    global_rng: Res<GlobalEntropy<WyRand>>,
    mut rng_history: ResMut<RngSnapshotHistory>,
) {
    // Save the current RNG state before a randomized action
    if let Some(serialized_state) = global_rng.try_serialize_state() {
        rng_history.snapshots.insert(sequence_tracker.current_sequence_id, serialized_state);
        
        // Clean up old snapshots if needed
        if rng_history.snapshots.len() > rng_history.max_snapshots {
            // Find and remove oldest snapshot
            if let Some(oldest_key) = rng_history.snapshots.keys()
                .min()
                .copied() {
                rng_history.snapshots.remove(&oldest_key);
            }
        }
    }
}
}

Client-Side Prediction

To minimize the perception of network issues, clients can implement prediction:

#![allow(unused)]
fn main() {
/// Resource to track client-side prediction state
#[derive(Resource)]
pub struct PredictionState {
    /// Actions predicted but not yet confirmed
    pub pending_actions: Vec<ActionRecord>,
    /// Whether prediction is currently active
    pub is_predicting: bool,
    /// Last confirmed server sequence ID
    pub last_confirmed_sequence: u64,
}

/// System to apply client-side prediction
pub fn apply_client_prediction(
    mut commands: Commands,
    mut game_state: ResMut<GameState>,
    mut prediction: ResMut<PredictionState>,
    input: Res<Input<KeyCode>>,
    client: Res<GameClient>,
) {
    // Only predict for local player actions
    if let Some(local_player) = client.local_player {
        // Check if a new action was input
        if input.just_pressed(KeyCode::Space) {
            // Example: Predict a "pass turn" action
            let action = GameAction::PassTurn { player: local_player };
            
            // Apply prediction locally
            apply_action_local(&mut commands, &mut game_state, action.clone());
            
            // Record the prediction
            prediction.pending_actions.push(ActionRecord {
                sequence_id: prediction.last_confirmed_sequence + prediction.pending_actions.len() as u64 + 1,
                player_id: local_player,
                timestamp: 0.0, // Will be filled by server
                action,
            });
            
            // Send to server
            // ...
        }
    }
}
}

Recovery Processes

When a network issue is detected, the recovery process begins:

#![allow(unused)]
fn main() {
/// Event triggered when a rollback is needed
#[derive(Event)]
pub struct RollbackEvent {
    /// The snapshot to roll back to
    pub snapshot: StateSnapshot,
    /// Reason for the rollback
    pub reason: RollbackReason,
    /// Clients affected by this rollback
    pub affected_clients: Vec<ClientId>,
}

/// Reasons for triggering a rollback
#[derive(Debug, Clone, Copy)]
pub enum RollbackReason {
    /// State divergence detected
    StateDivergence,
    /// Client reconnected after disconnect
    ClientReconnection,
    /// Server-forced rollback
    ServerForced,
    /// Desync in randomized outcome
    RandomizationDesync,
}

/// System to handle client reconnection with state recovery
pub fn handle_client_reconnection(
    mut commands: Commands,
    mut server: ResMut<GameServer>,
    mut server_events: EventReader<ServerEvent>,
    state_history: Res<StateHistory>,
    mut rollback_events: EventWriter<RollbackEvent>,
    client_states: Res<ClientStateTracker>,
) {
    for event in server_events.read() {
        if let ServerEvent::ClientConnected { client_id } = event {
            // Check if this is a reconnection
            if let Some(player_entity) = server.client_player_map.get(client_id) {
                // Find last known state for this client
                if let Some(last_known_sequence) = client_states.get_last_sequence(*client_id) {
                    // Find appropriate snapshot to roll back to
                    if let Some(snapshot) = find_appropriate_snapshot(&state_history, last_known_sequence) {
                        // Trigger rollback just for this client
                        rollback_events.send(RollbackEvent {
                            snapshot: snapshot.clone(),
                            reason: RollbackReason::ClientReconnection,
                            affected_clients: vec![*client_id],
                        });
                    }
                }
            }
        }
    }
}
}

Implementation Example

Complete Rollback Process

This example shows a complete rollback process after detecting a state divergence:

#![allow(unused)]
fn main() {
/// System to detect and handle state divergences
pub fn detect_state_divergences(
    mut commands: Commands,
    mut state_checksums: EventReader<StateChecksumEvent>,
    state_history: Res<StateHistory>,
    server: Option<Res<GameServer>>,
    mut rollback_events: EventWriter<RollbackEvent>,
) {
    // Only run on server
    if server.is_none() {
        return;
    }
    
    for checksum_event in state_checksums.read() {
        // Compare client checksum with server's expected checksum
        if checksum_event.client_checksum != checksum_event.expected_checksum {
            info!("State divergence detected for client {:?} at sequence {}",
                  checksum_event.client_id, checksum_event.sequence_id);
            
            // Find appropriate snapshot to roll back to
            if let Some(snapshot) = find_rollback_snapshot(&state_history, checksum_event.sequence_id) {
                // Trigger rollback for the affected client
                rollback_events.send(RollbackEvent {
                    snapshot: snapshot.clone(),
                    reason: RollbackReason::StateDivergence,
                    affected_clients: vec![checksum_event.client_id],
                });
                
                // Log the rollback event
                info!("Initiating rollback to sequence {} for client {:?}",
                      snapshot.sequence_id, checksum_event.client_id);
            }
        }
    }
}

/// Find an appropriate snapshot for rollback
fn find_rollback_snapshot(history: &StateHistory, divergence_sequence: u64) -> Option<&StateSnapshot> {
    // Find the most recent snapshot before the divergence
    history.snapshots
        .iter()
        .rev()
        .find(|snapshot| snapshot.sequence_id < divergence_sequence)
}

/// Apply a rollback
pub fn apply_rollback(
    mut commands: Commands,
    mut game_state: ResMut<GameState>,
    mut global_rng: ResMut<GlobalEntropy<WyRand>>,
    mut rollback_events: EventReader<RollbackEvent>,
    action_history: Res<ActionHistory>,
) {
    for event in rollback_events.read() {
        // 1. Restore game state from snapshot
        deserialize_game_state(&mut game_state, &event.snapshot.game_state);
        
        // 2. Restore RNG state
        global_rng.deserialize_state(&event.snapshot.rng_state)
            .expect("Failed to restore RNG state");
        
        // 3. Find actions that need to be replayed
        let actions_to_replay = action_history.get_actions_after(event.snapshot.sequence_id);
        
        // 4. Replay actions
        for action in actions_to_replay {
            apply_action(&mut commands, &mut game_state, &mut global_rng, action.clone());
        }
        
        // 5. Notify clients of the rollback
        for client_id in &event.affected_clients {
            commands.add(SendRollbackNotification {
                client_id: *client_id,
                snapshot: event.snapshot.clone(),
                reason: event.reason,
            });
        }
    }
}
}

Handling Randomized Actions During Rollback

Special consideration for randomized actions like card shuffling:

#![allow(unused)]
fn main() {
/// Apply an action during rollback replay
fn apply_action(
    commands: &mut Commands,
    game_state: &mut GameState,
    global_rng: &mut GlobalEntropy<WyRand>,
    action: ActionRecord,
) {
    match &action.action {
        GameAction::ShuffleLibrary { player, library } => {
            // For randomized actions, we need to ensure deterministic outcomes
            if let Ok(mut player_rng) = players.get_mut(action.player_id) {
                // Important: Use the RNG in a consistent way
                let mut library_entity = *library;
                let mut library_comp = game_state.get_library_mut(library_entity);
                
                // Deterministic shuffle using the player's RNG component
                library_comp.shuffle_with_rng(&mut player_rng.rng);
            }
        },
        GameAction::FlipCoin { player } => {
            // Another example of randomized action
            if let Ok(mut player_rng) = players.get_mut(action.player_id) {
                // The random result will be the same as the original action
                // if the RNG state is properly restored
                let result = player_rng.rng.gen_bool(0.5);
                
                // Apply the result
                game_state.record_coin_flip(*player, result);
            }
        },
        // Handle other action types
        _ => {
            // Apply non-randomized actions normally
            game_state.apply_action(&action.action);
        }
    }
}
}

Real-World Considerations

In practice, a rollback system needs to balance several considerations:

  1. Snapshot Frequency: More frequent snapshots use more memory but allow more precise rollbacks
  2. Rollback Visibility: How visible should rollbacks be to players?
  3. Partial vs. Full Rollbacks: Sometimes only a portion of the state needs rollback
  4. Action Batching: Batch multiple actions to minimize rollback frequency
  5. Bandwidth Costs: State synchronization requires bandwidth - optimize it

Optimizing for MTG Commander

For MTG Commander specifically:

  1. Take snapshots at natural game boundaries (turn changes, phase changes)
  2. Use incremental state updates between major decision points
  3. Maintain separate RNG state for "hidden information" actions like shuffling
  4. Prioritize server authority for rule enforcement and dispute resolution
  5. Enable client prediction for responsive UI during network hiccups

Deterministic Logic

Bevy Replicon Integration for Rollback with RNG State Management

This document details the integration of bevy_replicon with our rollback system, focusing on maintaining RNG state consistency across the network.

Table of Contents

  1. Introduction
  2. Replicon and RNG Integration
  3. Resources and Components
  4. Systems Integration
  5. State Preservation and Recovery
  6. Implementation Examples
  7. Performance Considerations
  8. Testing Guidelines
  9. Snapshot System Integration

Introduction

bevy_replicon is a lightweight, ECS-friendly networking library that provides replication for Bevy games. While it handles much of the complexity of network synchronization, maintaining deterministic RNG state during rollbacks requires additional mechanisms.

This document outlines how we extend bevy_replicon to handle RNG state management during network disruptions, ensuring all clients maintain identical random number sequences after recovery.

Replicon and RNG Integration

The key challenge is integrating bevy_replicon's entity replication with our RNG management system, particularly when:

  1. Replicating randomized game actions
  2. Handling rollbacks after connection interruptions
  3. Ensuring newly connected clients receive the correct RNG state
  4. Maintaining determinism during complex game scenarios

Our solution uses bevy_replicon's server-authoritative model but adds RNG state tracking and distribution mechanisms.

Architectural Overview

┌─────────────────────────────────────────────────────────────────────────┐
│                           SERVER                                         │
│                                                                         │
│  ┌───────────────┐     ┌──────────────────┐     ┌────────────────────┐  │
│  │               │     │                  │     │                    │  │
│  │  REPLICON     │────▶│  RNG STATE       │────▶│  GAME STATE       │  │
│  │  SERVER       │     │  MANAGER         │     │  MANAGER          │  │
│  │               │     │                  │     │                    │  │
│  └───────┬───────┘     └─────────┬────────┘     └────────┬───────────┘  │
│          │                       │                       │              │
│          │                       │                       │              │
│          ▼                       ▼                       ▼              │
│  ┌───────────────┐     ┌──────────────────┐     ┌────────────────────┐  │
│  │               │     │                  │     │                    │  │
│  │  REPLICON     │────▶│  ROLLBACK       │◀────│  SEQUENCE          │  │
│  │  REPLICATION  │     │  COORDINATOR    │     │  TRACKER           │  │
│  │               │     │                  │     │                    │  │
│  └───────────────┘     └──────────────────┘     └────────────────────┘  │
│                                                                         │
└─────────────────────────────────────────────────────────────────────────┘
                                    │
                                    │
                                    ▼
┌─────────────────────────────────────────────────────────────────────────┐
│                           CLIENT                                         │
│                                                                         │
│  ┌───────────────┐     ┌──────────────────┐     ┌────────────────────┐  │
│  │               │     │                  │     │                    │  │
│  │  REPLICON     │────▶│  RNG STATE       │────▶│  GAME STATE       │  │
│  │  CLIENT       │     │  APPLIER         │     │  RECEIVER         │  │
│  │               │     │                  │     │                    │  │
│  └───────┬───────┘     └─────────┬────────┘     └────────┬───────────┘  │
│          │                       │                       │              │
│          │                       │                       │              │
│          ▼                       ▼                       ▼              │
│  ┌───────────────┐     ┌──────────────────┐     ┌────────────────────┐  │
│  │               │     │                  │     │                    │  │
│  │  LOCAL        │────▶│  PREDICTION     │◀────│  HISTORY           │  │
│  │  RNG MANAGER  │     │  RECONCILIATION │     │  TRACKER           │  │
│  │               │     │                  │     │                    │  │
│  └───────────────┘     └──────────────────┘     └────────────────────┘  │
│                                                                         │
└─────────────────────────────────────────────────────────────────────────┘

Resources and Components

Core Resources

#![allow(unused)]
fn main() {
/// Resource that tracks RNG state for replication
#[derive(Resource)]
pub struct RngReplicationState {
    /// Current global RNG state
    pub global_state: Vec<u8>,
    /// Player-specific RNG states
    pub player_states: HashMap<Entity, Vec<u8>>,
    /// Sequence number for the latest RNG state update
    pub sequence: u64,
    /// Timestamp of the last update
    pub last_update: f32,
    /// Flag indicating the state has changed
    pub dirty: bool,
}

/// Resource for rollback checkpoints with RNG state
#[derive(Resource)]
pub struct RollbackCheckpoints {
    /// Checkpoints with sequence IDs as keys
    pub checkpoints: BTreeMap<u64, RollbackCheckpoint>,
    /// Maximum number of checkpoints to maintain
    pub max_checkpoints: usize,
}

/// Structure for a single rollback checkpoint
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct RollbackCheckpoint {
    /// Checkpoint sequence ID
    pub sequence_id: u64,
    /// Timestamp of the checkpoint
    pub timestamp: f32,
    /// Global RNG state
    pub global_rng_state: Vec<u8>,
    /// Player-specific RNG states
    pub player_rng_states: HashMap<Entity, Vec<u8>>,
    /// Replicated entities snapshot
    pub replicated_entities: Vec<EntityData>,
}

/// Replicon channel for RNG synchronization
#[derive(Default)]
pub struct RngSyncChannel;

/// Extension for RepliconServer to handle RNG state
impl RepliconServerExt for RepliconServer {
    /// Send RNG state to a specific client
    fn send_rng_state(&mut self, client_id: ClientId, rng_state: &RngReplicationState) {
        let message = RngStateMessage {
            global_state: rng_state.global_state.clone(),
            player_states: rng_state.player_states.clone(),
            sequence: rng_state.sequence,
            timestamp: rng_state.last_update,
        };
        
        self.send_message(client_id, RngSyncChannel, bincode::serialize(&message).unwrap());
    }
    
    /// Broadcast RNG state to all clients
    fn broadcast_rng_state(&mut self, rng_state: &RngReplicationState) {
        let message = RngStateMessage {
            global_state: rng_state.global_state.clone(),
            player_states: rng_state.player_states.clone(),
            sequence: rng_state.sequence,
            timestamp: rng_state.last_update,
        };
        
        self.broadcast_message(RngSyncChannel, bincode::serialize(&message).unwrap());
    }
}
}

Components for Entity Tracking

#![allow(unused)]
fn main() {
/// Component to flag an entity as having randomized behavior
#[derive(Component, Reflect, Default)]
pub struct RandomizedBehavior {
    /// The last RNG sequence ID used for this entity
    pub last_rng_sequence: u64,
    /// Whether this entity has pending randomized actions
    pub has_pending_actions: bool,
}

/// Component for player-specific RNG
#[derive(Component, Reflect)]
pub struct PlayerRng {
    /// Sequence of the last RNG state
    pub sequence: u64,
    /// Whether this RNG is remote (on another client)
    pub is_remote: bool,
}
}

Snapshot System Integration

For detailed information about how the replicon rollback system integrates with the snapshot system, please refer to the centralized Snapshot System documentation:

The snapshot system provides the serialization and deserialization capabilities needed for the rollback system, while the replicon integration described in this document ensures proper handling of RNG state during network operations.

Key integration points include:

  1. RNG State Capture: The snapshot system captures RNG state alongside other game state
  2. Deterministic Rollback: Integration ensures that RNG sequences remain identical after rollback
  3. Client Synchronization: New clients receive correct RNG state as part of their initial snapshot
  4. Networked Events: Random events are processed deterministically across all clients

For more detailed implementation examples of how to use these systems together, see the Implementation Examples section below and the Network Snapshot Testing section in the snapshot system documentation.

Systems Integration

Server-Side Systems

#![allow(unused)]
fn main() {
/// Plugin that integrates bevy_replicon with our RNG and rollback systems
pub struct RepliconRngRollbackPlugin;

impl Plugin for RepliconRngRollbackPlugin {
    fn build(&self, app: &mut App) {
        // Register network channel
        app.register_network_channel::<RngSyncChannel>(ChannelConfig {
            channel_id: 100, // Use a unique channel ID
            mode: ChannelMode::Unreliable,
        });
        
        // Add resources
        app.init_resource::<RngReplicationState>()
            .init_resource::<RollbackCheckpoints>();
            
        // Server systems
        app.add_systems(Update, (
            capture_rng_state,
            replicate_rng_state,
            create_rollback_checkpoints,
        ).run_if(resource_exists::<RepliconServer>()));
        
        // Client systems
        app.add_systems(Update, (
            apply_rng_state_updates,
            handle_rollback_requests,
        ).run_if(resource_exists::<RepliconClient>()));
    }
}

/// System to capture RNG state for replication
pub fn capture_rng_state(
    mut global_rng: ResMut<GlobalEntropy<WyRand>>,
    player_rngs: Query<(Entity, &PlayerRng)>,
    mut rng_state: ResMut<RngReplicationState>,
    time: Res<Time>,
    sequence: Res<SequenceTracker>,
) {
    // Don't update too frequently
    if time.elapsed_seconds() - rng_state.last_update < 1.0 {
        return;
    }
    
    // Capture global RNG state
    if let Some(state) = global_rng.try_serialize_state() {
        rng_state.global_state = state;
        rng_state.dirty = true;
    }
    
    // Capture player RNG states
    for (entity, _) in player_rngs.iter() {
        if let Some(player_rng) = player_rngs.get_component::<Entropy<WyRand>>(entity).ok() {
            if let Some(state) = player_rng.try_serialize_state() {
                rng_state.player_states.insert(entity, state);
                rng_state.dirty = true;
            }
        }
    }
    
    if rng_state.dirty {
        rng_state.sequence = sequence.current_sequence;
        rng_state.last_update = time.elapsed_seconds();
    }
}

/// System to replicate RNG state to clients
pub fn replicate_rng_state(
    mut server: ResMut<RepliconServer>,
    rng_state: Res<RngReplicationState>,
) {
    if rng_state.dirty {
        server.broadcast_rng_state(&rng_state);
    }
}

/// System to create rollback checkpoints
pub fn create_rollback_checkpoints(
    mut checkpoints: ResMut<RollbackCheckpoints>,
    rng_state: Res<RngReplicationState>,
    time: Res<Time>,
    replicated_query: Query<Entity, With<Replication>>,
    entity_data: Res<EntityData>,
) {
    // Create a new checkpoint every few seconds
    if time.elapsed_seconds() % 5.0 < 0.1 {
        // Collect replicated entity data
        let mut entities = Vec::new();
        for entity in replicated_query.iter() {
            if let Some(data) = entity_data.get_entity_data(entity) {
                entities.push(data.clone());
            }
        }
        
        // Create checkpoint
        let checkpoint = RollbackCheckpoint {
            sequence_id: rng_state.sequence,
            timestamp: time.elapsed_seconds(),
            global_rng_state: rng_state.global_state.clone(),
            player_rng_states: rng_state.player_states.clone(),
            replicated_entities: entities,
        };
        
        // Add to checkpoints
        checkpoints.checkpoints.insert(rng_state.sequence, checkpoint);
        
        // Prune old checkpoints
        while checkpoints.checkpoints.len() > checkpoints.max_checkpoints {
            if let Some((&oldest_key, _)) = checkpoints.checkpoints.iter().next() {
                checkpoints.checkpoints.remove(&oldest_key);
            }
        }
    }
}
}

Client-Side Systems

#![allow(unused)]
fn main() {
/// System to apply RNG state updates from server
pub fn apply_rng_state_updates(
    mut client: ResMut<RepliconClient>,
    mut global_rng: ResMut<GlobalEntropy<WyRand>>,
    mut player_rngs: Query<(Entity, &mut PlayerRng)>,
    mut events: EventReader<NetworkEvent>,
) {
    for event in events.read() {
        if let NetworkEvent::Message(_, RngSyncChannel, data) = event {
            // Deserialize the RNG state message
            if let Ok(message) = bincode::deserialize::<RngStateMessage>(data) {
                // Apply global RNG state
                if !message.global_state.is_empty() {
                    global_rng.deserialize_state(&message.global_state)
                        .expect("Failed to deserialize global RNG state");
                }
                
                // Apply player-specific RNG states
                for (entity, mut player_rng) in player_rngs.iter_mut() {
                    if let Some(state) = message.player_states.get(&entity) {
                        if let Some(rng) = player_rngs.get_component_mut::<Entropy<WyRand>>(entity).ok() {
                            rng.deserialize_state(state).expect("Failed to deserialize player RNG state");
                            player_rng.sequence = message.sequence;
                        }
                    }
                }
            }
        }
    }
}

/// System to handle rollback requests
pub fn handle_rollback_requests(
    mut client: ResMut<RepliconClient>,
    mut global_rng: ResMut<GlobalEntropy<WyRand>>,
    mut player_rngs: Query<(Entity, &mut PlayerRng)>,
    mut events: EventReader<NetworkEvent>,
    mut commands: Commands,
) {
    for event in events.read() {
        if let NetworkEvent::Message(_, RollbackChannel, data) = event {
            // Deserialize the rollback message
            if let Ok(message) = bincode::deserialize::<RollbackMessage>(data) {
                // Apply global RNG state from the checkpoint
                if !message.checkpoint.global_rng_state.is_empty() {
                    global_rng.deserialize_state(&message.checkpoint.global_rng_state)
                        .expect("Failed to deserialize checkpoint RNG state");
                }
                
                // Apply player-specific RNG states from the checkpoint
                for (entity, mut player_rng) in player_rngs.iter_mut() {
                    if let Some(state) = message.checkpoint.player_rng_states.get(&entity) {
                        if let Some(rng) = player_rngs.get_component_mut::<Entropy<WyRand>>(entity).ok() {
                            rng.deserialize_state(state).expect("Failed to deserialize player RNG state");
                            player_rng.sequence = message.checkpoint.sequence_id;
                        }
                    }
                }
                
                // Restore entity state from checkpoint
                for entity_data in &message.checkpoint.replicated_entities {
                    // Restore entity or spawn if it doesn't exist
                    // ...
                }
                
                info!("Applied rollback to sequence {}", message.checkpoint.sequence_id);
            }
        }
    }
}
}

State Preservation and Recovery

The rollback process occurs in these steps:

  1. Detection: Server detects desynchronization (via mismatch in action results)
  2. Checkpoint Selection: Server selects appropriate rollback checkpoint
  3. Notification: Server notifies affected clients of rollback
  4. State Restoration: Both server and clients:
    • Restore game state
    • Restore RNG state
    • Replay necessary actions
  5. Verification: Server verifies all clients are synchronized

Rollback Protocol

#![allow(unused)]
fn main() {
/// Enum for rollback types
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub enum RollbackType {
    /// Full rollback with complete state restoration
    Full,
    /// Partial rollback for specific entities only
    Partial,
    /// RNG-only rollback for randomization issues
    RngOnly,
}

/// Message for rollback requests
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RollbackMessage {
    /// Type of rollback
    pub rollback_type: RollbackType,
    /// Rollback checkpoint
    pub checkpoint: RollbackCheckpoint,
    /// Reason for rollback
    pub reason: String,
}
}

Implementation Examples

Example 1: Rollback After Network Interruption

#![allow(unused)]
fn main() {
/// System to detect and handle network interruptions
pub fn handle_network_interruption(
    mut server: ResMut<RepliconServer>,
    checkpoints: Res<RollbackCheckpoints>,
    clients: Query<(Entity, &ClientConnection)>,
    time: Res<Time>,
) {
    // Check for clients with high latency or disconnection
    for (entity, connection) in clients.iter() {
        if connection.latency > 1.0 || !connection.connected {
            // Find most recent valid checkpoint
            if let Some((_, checkpoint)) = checkpoints.checkpoints.iter().rev().next() {
                // Initiate rollback for all clients
                let rollback_message = RollbackMessage {
                    rollback_type: RollbackType::Full,
                    checkpoint: checkpoint.clone(),
                    reason: "Network interruption detected".to_string(),
                };
                
                // Send to all clients
                server.broadcast_message(RollbackChannel, bincode::serialize(&rollback_message).unwrap());
                
                // Apply rollback on server too
                apply_rollback_on_server(&rollback_message);
                
                info!("Initiated rollback due to network interruption");
            }
            break;
        }
    }
}
}

Example 2: Handling Card Shuffle During Rollback

#![allow(unused)]
fn main() {
/// System to handle card shuffling during or after a rollback
pub fn handle_shuffle_during_rollback(
    mut commands: Commands,
    mut shuffle_events: EventReader<ShuffleLibraryEvent>,
    mut global_rng: ResMut<GlobalEntropy<WyRand>>,
    player_rngs: Query<(Entity, &PlayerRng)>,
    libraries: Query<(Entity, &Library, &Parent)>,
) {
    for event in shuffle_events.read() {
        if let Ok((library_entity, library, parent)) = libraries.get(event.library_entity) {
            // Get the player entity (parent)
            let player_entity = parent.get();
            
            // Get player's RNG
            if let Ok((_, player_rng)) = player_rngs.get(player_entity) {
                // Use the appropriate RNG for deterministic shuffle
                let mut card_indices: Vec<usize> = (0..library.cards.len()).collect();
                
                if player_rng.is_remote {
                    // Use global RNG for remote player to ensure consistency
                    for i in (1..card_indices.len()).rev() {
                        let j = global_rng.gen_range(0..=i);
                        card_indices.swap(i, j);
                    }
                } else {
                    // Use player-specific RNG for local player
                    if let Some(rng) = player_rngs.get_component::<Entropy<WyRand>>(player_entity).ok() {
                        for i in (1..card_indices.len()).rev() {
                            let j = rng.gen_range(0..=i);
                            card_indices.swap(i, j);
                        }
                    }
                }
                
                // Apply shuffle result
                // ...
                
                info!("Performed deterministic shuffle during/after rollback");
            }
        }
    }
}
}

Performance Considerations

When implementing RNG state management with bevy_replicon and rollbacks, consider these performance factors:

  1. RNG State Size:

    • WyRand has a compact 8-byte state, ideal for frequent replication
    • More complex PRNGs may have larger states, increasing network overhead
  2. Checkpoint Frequency:

    • More frequent checkpoints = better recovery granularity but higher overhead
    • Recommended: 5-10 second intervals for most games
  3. Selective Replication:

    • Only replicate RNG state when it changes significantly
    • Consider checksums to detect state changes efficiently
  4. Bandwidth Usage:

    • Use the appropriate channel mode (reliable for critical RNG updates)
    • Batch RNG updates with other state replication when possible
  5. Memory Overhead:

    • Limit maximum checkpoints based on available memory (10-20 is reasonable)
    • Use sliding window approach to discard old checkpoints

Testing Guidelines

For effective testing of replicon-based RNG rollback, follow these approaches:

  1. Determinism Tests:

    • Verify identical seeds produce identical sequences on all clients
    • Test saving and restoring RNG state produces identical future values
  2. Network Disruption Tests:

    • Simulate connection drops to trigger rollback
    • Verify game state remains consistent after recovery
  3. Performance Tests:

    • Measure impact of RNG state replication on bandwidth
    • Profile checkpoint creation and restoration overhead
  4. Integration Tests:

    • Test complex game scenarios like multi-player card shuffling
    • Verify random outcomes remain consistent across network boundaries

For detailed testing examples, see the RNG Synchronization Tests document.


By following these guidelines, you can create a robust integration between bevy_replicon, our rollback system, and RNG state management that maintains deterministic behavior even during network disruptions.

Action Broadcasting

Latency Compensation

Player Departure Handling

This section covers the handling of player departures in networked gameplay, focusing on maintaining game integrity when players disconnect or leave a game in progress.

Overview

In multiplayer games, especially Commander format, player departures can significantly impact gameplay. This system handles:

  • Graceful disconnections (player intentionally leaves)
  • Unexpected disconnections (network failures, crashes)
  • Temporary disconnections with reconnection potential
  • Permanent departures that require game state adjustments

Components

Departure Handling

Detailed implementation of how player departures are processed, including:

  • State preservation for potential reconnection
  • Game state adjustments when a player permanently leaves
  • Handling of in-progress actions when a player disconnects
  • Object ownership transfer upon player departure
  • AI takeover options for departed players

Integration

This system integrates closely with:

Game Departure Handling

This document describes how the system handles players leaving an active Commander game, whether through quitting, being kicked, or disconnection. Handling player departures properly is crucial for maintaining game integrity and player experience.

Table of Contents

  1. Overview
  2. Departure Scenarios
  3. Game State Preservation
  4. UI Experience
  5. Reconnection Flow
  6. Implementation

Overview

In a multiplayer Commander game, players may leave for various reasons, and the game must handle these departures gracefully. The system differentiates between different types of departures and provides appropriate mechanisms for each.

┌───────────────────┐
│                   │
│   Active Game     │◄───── New Player Joining (Spectator)
│                   │
└─────────┬─────────┘
          │
          ▼
┌───────────────────┐
│  Player Departure │
└─────────┬─────────┘
          │
          ▼
┌───────────────────┬────────────────┬───────────────────┐
│                   │                │                   │
│ Voluntary Quit    │ Kicked by Host │ Disconnection     │
│                   │                │                   │
└─────────┬─────────┴────────┬───────┴─────────┬─────────┘
          │                  │                 │
          ▼                  ▼                 ▼
┌───────────────────┐ ┌──────────────┐ ┌───────────────────┐
│ Return to Menu    │ │ Menu + Notif │ │ Reconnect Window  │
└───────────────────┘ └──────────────┘ └─────────┬─────────┘
                                                 │
                                                 ▼
                                       ┌───────────────────┐
                                       │  Success/Failure  │
                                       └───────────────────┘

Departure Scenarios

Voluntary Quitting

When a player intentionally quits a game:

  1. Player selects "Quit Game" from the pause menu
  2. A confirmation dialog appears
  3. Upon confirmation, a quit message is sent to the server
  4. The server processes the departure and notifies other players
  5. Game state is updated to mark the player as departed
  6. The quitting player returns to the main menu
#![allow(unused)]
fn main() {
/// System to handle quit game request
fn handle_quit_game(
    mut interaction_query: Query<&Interaction, (Changed<Interaction>, With<QuitGameButton>)>,
    mut confirmation_dialog: ResMut<ConfirmationDialog>,
) {
    for interaction in &mut interaction_query {
        if *interaction == Interaction::Pressed {
            // Show confirmation dialog
            confirmation_dialog.show(
                "Quit Game?",
                "Are you sure you want to quit this game? Your progress will be lost.",
                ConfirmationAction::QuitGame,
            );
        }
    }
}

/// System to process quit confirmation
fn process_quit_confirmation(
    mut confirmation_events: EventReader<ConfirmationResponse>,
    mut game_connection: ResMut<GameConnection>,
    mut next_state: ResMut<NextState<GameMenuState>>,
) {
    for event in confirmation_events.read() {
        if event.action == ConfirmationAction::QuitGame && event.confirmed {
            // Send quit notification to server
            game_connection.send_departure_notification(DepartureReason::Voluntary);
            
            // Return to main menu
            next_state.set(GameMenuState::MainMenu);
        }
    }
}
}

Being Kicked

When a player is kicked by the host:

  1. Host opens player menu and selects "Kick Player"
  2. A confirmation dialog appears for the host
  3. Upon confirmation, a kick message is sent to the server
  4. The server validates the request and processes the kick
  5. The kicked player receives a notification
  6. The kicked player's UI transitions to the main menu with a message
  7. Other players are notified that the player was kicked
#![allow(unused)]
fn main() {
/// Host-side kick request
fn handle_kick_player_request(
    mut interaction_query: Query<(&Interaction, &PlayerTarget), (Changed<Interaction>, With<KickPlayerButton>)>,
    mut confirmation_dialog: ResMut<ConfirmationDialog>,
) {
    for (interaction, target) in &mut interaction_query {
        if *interaction == Interaction::Pressed {
            // Show confirmation dialog for host
            confirmation_dialog.show(
                "Kick Player?",
                &format!("Are you sure you want to kick {}?", target.name),
                ConfirmationAction::KickPlayer(target.player_id.clone()),
            );
        }
    }
}

/// System to handle being kicked from a game
fn handle_being_kicked_from_game(
    mut kick_events: EventReader<KickedFromGameEvent>,
    mut commands: Commands,
    mut next_state: ResMut<NextState<GameMenuState>>,
) {
    for event in kick_events.read() {
        // Display kicked message
        commands.spawn(KickedGameNotificationUI {
            reason: event.reason.clone(),
        });
        
        // Return to main menu
        next_state.set(GameMenuState::MainMenu);
    }
}
}

Disconnection

When a player disconnects unexpectedly:

  1. The server detects a connection drop
  2. The server keeps the player's game state for a reconnection window
  3. Other players see the disconnected player's status change
  4. If the player reconnects within the window, they rejoin seamlessly
  5. If the reconnection window expires, the player is fully removed
#![allow(unused)]
fn main() {
/// System to handle disconnections in active games
fn handle_game_disconnection(
    mut disconnection_events: EventReader<PlayerDisconnectedGameEvent>,
    mut game_state: ResMut<GameState>,
    mut player_query: Query<(&mut PlayerComponent, &NetworkId)>,
) {
    for event in disconnection_events.read() {
        // Find and update player state
        for (mut player, network_id) in &mut player_query {
            if network_id.0 == event.player_id {
                player.connection_status = ConnectionStatus::Disconnected;
                player.reconnection_timer = Some(GAME_RECONNECTION_WINDOW);
                
                // Pause the player's turn if active
                if game_state.active_player == event.player_id {
                    game_state.active_turn_paused = true;
                    game_state.pause_reason = PauseReason::PlayerDisconnected;
                }
                
                break;
            }
        }
    }
}

/// System to handle reconnection windows
fn update_reconnection_timers(
    time: Res<Time>,
    mut game_state: ResMut<GameState>,
    mut player_query: Query<(&mut PlayerComponent, &NetworkId)>,
    mut commands: Commands,
) {
    for (mut player, network_id) in &mut player_query {
        if let Some(timer) = &mut player.reconnection_timer {
            *timer -= time.delta_seconds();
            
            if *timer <= 0.0 {
                // Reconnection window expired, remove player
                player.connection_status = ConnectionStatus::Left;
                player.reconnection_timer = None;
                
                // If it was their turn, pass to next player
                if game_state.active_player == network_id.0 {
                    commands.add(PassTurn);
                }
            }
        }
    }
}
}

Game State Preservation

When a player leaves a Commander game, their game state is handled according to the format rules:

  1. Permanent Departure: If a player quits, is kicked, or their reconnection window expires:

    • Their cards remain in play until they would naturally leave the battlefield
    • Their life total is set to 0 for Commander damage calculations
    • Their turns are skipped
    • They are no longer a valid target for spells/abilities that target players
    • Effects they controlled continue to function as normal until they expire
  2. Temporary Disconnection: If a player disconnects but can reconnect:

    • Their game state is fully preserved
    • Their turn is paused if it was active
    • Other players can continue to play
    • The game resumes normally once they reconnect
#![allow(unused)]
fn main() {
/// Handle permanent player departure
fn handle_permanent_departure(
    mut commands: Commands,
    departure_event: Res<PlayerDepartureEvent>,
    mut game_state: ResMut<GameState>,
    player_query: Query<(Entity, &NetworkId, &PlayerComponent)>,
    card_query: Query<(Entity, &Owner, &Zone)>,
) {
    // Find the departed player's entity
    for (player_entity, network_id, player) in player_query.iter() {
        if network_id.0 == departure_event.player_id {
            // Mark player as departed in game state
            commands.entity(player_entity).insert(DepartedPlayer);
            
            // Set life total to 0 for commander damage calculations
            commands.entity(player_entity).insert(LifeTotal(0));
            
            // Handle active effects
            handle_departed_player_effects(commands, player_entity, &card_query);
            
            // If it was their turn, pass to next player
            if game_state.active_player == network_id.0 {
                commands.add(PassTurn);
            }
            
            break;
        }
    }
}
}

UI Experience

The user interface handles departures with appropriate feedback:

For the Departing Player

  • Voluntary Quit: Simple transition to main menu
  • Kicked: Notification explaining they were kicked before returning to main menu
  • Disconnection: Reconnection attempts with progress indicators

For Remaining Players

  • Player Quit: Notification of player departure
  • Player Kicked: Notification of player being kicked
  • Disconnection: Status indicator showing disconnected state and reconnection attempt
#![allow(unused)]
fn main() {
/// UI component for disconnection status
#[derive(Component)]
pub struct DisconnectionUI {
    /// Disconnected player name
    player_name: String,
    /// Time remaining in reconnection window
    time_remaining: f32,
}

/// System to update disconnection UI
fn update_disconnection_ui(
    mut disconnection_query: Query<(&mut Text, &mut DisconnectionUI)>,
    time: Res<Time>,
) {
    for (mut text, mut disconnection) in &mut disconnection_query {
        // Update remaining time
        disconnection.time_remaining -= time.delta_seconds();
        
        // Update display text
        text.sections[0].value = format!(
            "{} disconnected. Reconnecting... ({}s)",
            disconnection.player_name,
            disconnection.time_remaining as u32
        );
        
        // Change text color based on time remaining
        if disconnection.time_remaining < 10.0 {
            text.sections[0].style.color = Color::RED;
        }
    }
}
}

Reconnection Flow

The reconnection process involves several steps:

  1. Detection: Client detects lost connection to the game server
  2. Retry: Client attempts to reconnect automatically
  3. Authentication: Upon reconnection, client provides game session token
  4. State Sync: Server sends complete game state to the reconnected client
  5. Resumption: Game continues with the reconnected player
#![allow(unused)]
fn main() {
/// Reconnection sequence
fn handle_reconnection(
    mut connection: ResMut<GameConnection>,
    mut game_state: ResMut<LocalGameState>,
    mut next_state: ResMut<NextState<ReconnectionState>>,
) {
    match *next_state.get() {
        ReconnectionState::Disconnected => {
            // Attempt to reconnect
            if connection.attempt_reconnect() {
                next_state.set(ReconnectionState::Connecting);
            }
        }
        ReconnectionState::Connecting => {
            // Check connection status
            if connection.is_connected() {
                next_state.set(ReconnectionState::Authenticating);
            } else if connection.attempts_exhausted() {
                next_state.set(ReconnectionState::Failed);
            }
        }
        ReconnectionState::Authenticating => {
            // Authenticate with game session
            if connection.authenticate_session() {
                next_state.set(ReconnectionState::SyncingState);
            }
        }
        ReconnectionState::SyncingState => {
            // Receive and process game state
            if game_state.is_synchronized() {
                next_state.set(ReconnectionState::Completed);
            }
        }
        ReconnectionState::Completed => {
            // Resume game
            next_state.set(ReconnectionState::None);
        }
        ReconnectionState::Failed => {
            // Return to main menu with error
            // ...
        }
        _ => {}
    }
}
}

Implementation

Message Types

#![allow(unused)]
fn main() {
/// Game departure notification
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct GameDepartureNotification {
    /// Player ID that is departing
    pub player_id: String,
    /// Reason for departure
    pub reason: DepartureReason,
    /// Timestamp of departure
    pub timestamp: f64,
}

/// Reasons for player departure
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
pub enum DepartureReason {
    /// Player chose to leave
    Voluntary,
    /// Player was kicked by the host
    Kicked,
    /// Player disconnected unexpectedly
    Disconnected,
    /// Server closed the game
    ServerClosure,
    /// Game ended normally
    GameCompleted,
}

/// Reconnection request
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct ReconnectionRequest {
    /// Game ID to reconnect to
    pub game_id: String,
    /// Player ID reconnecting
    pub player_id: String,
    /// Authentication token
    pub auth_token: String,
    /// Last known game tick
    pub last_tick: u64,
}
}

Systems

The implementation includes dedicated systems to handle the various player departure scenarios:

#![allow(unused)]
fn main() {
/// Register game departure systems
pub fn register_departure_systems(app: &mut App) {
    app
        .add_event::<PlayerDepartureEvent>()
        .add_event::<KickedFromGameEvent>()
        .add_event::<PlayerDisconnectedGameEvent>()
        .add_event::<ReconnectionEvent>()
        .add_systems(Update, (
            handle_quit_game,
            process_quit_confirmation,
            handle_kick_player_request,
            handle_being_kicked_from_game,
            handle_game_disconnection,
            update_reconnection_timers,
            handle_permanent_departure,
            update_disconnection_ui,
            handle_reconnection,
        ));
}
}

These systems work together to provide a seamless experience for players during game departures, ensuring the game remains playable and enjoyable for those who remain.

Game State Synchronization

This section covers the synchronization mechanisms used to ensure consistent game state across all clients in a networked Magic: The Gathering Commander game.

Overview

State synchronization is critical for maintaining a fair and consistent gameplay experience. Our implementation focuses on:

  • Deterministic gameplay logic
  • Efficient state updates
  • Handling of random elements
  • Resolution of inconsistencies
  • Hidden information management

Components

RNG Rollback

Implementation details for synchronizing random number generation across clients, including:

  • Deterministic RNG sequence generation
  • Seed management
  • Rollback capabilities for RNG state
  • Verification mechanisms for RNG consistency

Integration

This system integrates closely with:

Testing

Comprehensive testing of synchronization mechanisms is covered in the Testing section, with specific focus on:

RNG Synchronization and Rollback Integration

This document explains how the random number generator (RNG) synchronization system integrates with the state rollback mechanism to maintain consistent game state across network disruptions.

Table of Contents

  1. Overview
  2. Challenges
  3. Integration Architecture
  4. Implementation Details
  5. Example Scenarios
  6. Performance Considerations

Overview

Card games like Magic: The Gathering require randomization for shuffling libraries, coin flips, dice rolls, and "random target" selection. In a networked environment, these random operations must produce identical results across all clients despite network disruptions. Our solution combines the RNG synchronization system with the state rollback mechanism to ensure consistent gameplay.

Challenges

Several challenges must be addressed:

  1. Deterministic Recovery: After a network disruption, random operations must produce the same results as before
  2. Hidden Information: RNG state must be preserved without revealing hidden information (like library order)
  3. Partial Rollbacks: Some clients may need to roll back while others do not
  4. Performance: RNG state serialization and transmission must be efficient
  5. Cheating Prevention: The system must prevent manipulation of random outcomes

Integration Architecture

The integration of RNG synchronization with the rollback system follows this architecture:

┌────────────────────┐     ┌───────────────────┐     ┌────────────────────┐
│                    │     │                   │     │                    │
│  RNG STATE SYSTEM  │◄────┤  ROLLBACK SYSTEM  │────►│  GAME STATE SYSTEM │
│                    │     │                   │     │                    │
└───────┬────────────┘     └─────────┬─────────┘     └─────────┬──────────┘
        │                            │                         │
        │                            │                         │
        ▼                            ▼                         ▼
┌────────────────────┐     ┌───────────────────┐     ┌────────────────────┐
│                    │     │                   │     │                    │
│  HISTORY TRACKER   │◄────┤ SEQUENCE TRACKER  │────►│  ACTION PROCESSOR  │
│                    │     │                   │     │                    │
└────────────────────┘     └───────────────────┘     └────────────────────┘

Key Components

  1. RNG State System: Manages the global and player-specific RNG states
  2. Rollback System: Handles state rollbacks due to network disruptions
  3. Game State System: Maintains the authoritative game state
  4. History Tracker: Records RNG states at key sequence points
  5. Sequence Tracker: Assigns sequence IDs to game actions
  6. Action Processor: Applies game actions deterministically

Implementation Details

RNG State Snapshots

For every game state snapshot, we also capture the corresponding RNG state:

#![allow(unused)]
fn main() {
/// System to capture RNG state with game state snapshots
pub fn capture_rng_with_state_snapshot(
    mut state_history: ResMut<StateHistory>,
    global_rng: Res<GlobalEntropy<WyRand>>,
    players: Query<(Entity, &PlayerRng)>,
) {
    if let Some(current_snapshot) = state_history.snapshots.last_mut() {
        // Save global RNG state
        if let Some(global_state) = global_rng.try_serialize_state() {
            current_snapshot.rng_state = global_state;
        }
        
        // Save player-specific RNG states
        let mut player_rng_states = HashMap::new();
        for (entity, player_rng) in players.iter() {
            if let Some(state) = player_rng.rng.try_serialize_state() {
                player_rng_states.insert(entity, state);
            }
        }
        current_snapshot.player_rng_states = player_rng_states;
    }
}
}

Randomized Action Handling

Randomized actions require special attention during rollbacks:

#![allow(unused)]
fn main() {
/// Apply a randomized action with RNG state consistency
pub fn apply_randomized_action(
    action: &GameAction,
    rng_state: Option<&[u8]>,
    global_rng: &mut GlobalEntropy<WyRand>,
    player_rngs: &mut Query<&mut PlayerRng>,
) -> ActionResult {
    // If we have a saved RNG state for this action, restore it first
    if let Some(state) = rng_state {
        global_rng.deserialize_state(state).expect("Failed to restore RNG state");
    }
    
    match action {
        GameAction::ShuffleLibrary { player, library } => {
            // Get the player's RNG component
            if let Ok(mut player_rng) = player_rngs.get_mut(*player) {
                // Perform the shuffle using the player's RNG
                // This will produce the same result if the RNG state is the same
                // ...
                
                ActionResult::Success
            } else {
                ActionResult::PlayerNotFound
            }
        },
        // Handle other randomized actions...
        _ => ActionResult::NotRandomized,
    }
}
}

Rollback With RNG Recovery

During a rollback, both game state and RNG state are restored:

#![allow(unused)]
fn main() {
/// System to perform a rollback with RNG state recovery
pub fn perform_rollback_with_rng(
    mut game_state: ResMut<GameState>,
    mut global_rng: ResMut<GlobalEntropy<WyRand>>,
    mut player_rngs: Query<(Entity, &mut PlayerRng)>,
    rollback_event: Res<RollbackEvent>,
) {
    // Restore game state
    deserialize_game_state(&mut game_state, &rollback_event.snapshot.game_state);
    
    // Restore global RNG state
    global_rng.deserialize_state(&rollback_event.snapshot.rng_state)
        .expect("Failed to restore global RNG state");
    
    // Restore player-specific RNG states
    for (entity, mut player_rng) in player_rngs.iter_mut() {
        if let Some(state) = rollback_event.snapshot.player_rng_states.get(&entity) {
            player_rng.rng.deserialize_state(state)
                .expect("Failed to restore player RNG state");
        }
    }
    
    // Log the rollback
    info!("Performed rollback to sequence {} with RNG state recovery", 
          rollback_event.snapshot.sequence_id);
}
}

Example Scenarios

Scenario 1: Library Shuffle During Network Disruption

  1. Player A initiates a library shuffle
  2. Network disruption occurs during processing
  3. System detects the disruption and initiates rollback
  4. RNG state from before the shuffle is restored
  5. Shuffle action is replayed with identical RNG state
  6. All clients observe the same shuffle outcome despite the disruption
#![allow(unused)]
fn main() {
// Handling a shuffle during network disruption
pub fn handle_shuffle_during_disruption(
    mut commands: Commands,
    mut game_state: ResMut<GameState>,
    mut global_rng: ResMut<GlobalEntropy<WyRand>>,
    mut player_rngs: Query<&mut PlayerRng>,
    mut rollback_events: EventReader<RollbackEvent>,
    action_history: Res<ActionHistory>,
) {
    for event in rollback_events.read() {
        // Restore game and RNG state
        perform_rollback_with_rng(&mut game_state, &mut global_rng, &mut player_rngs, event);
        
        // Get actions to replay
        let actions = action_history.get_actions_after(event.snapshot.sequence_id);
        
        // Replay actions deterministically
        for action in actions {
            // If this is a shuffle action, it will produce the same result
            // because the RNG state has been restored
            apply_action(&mut commands, &mut game_state, &mut global_rng, &mut player_rngs, &action);
        }
    }
}
}

Scenario 2: Client Reconnection After Multiple Random Events

  1. Client disconnects during a game with several random actions
  2. Random actions continue to occur (coin flips, shuffles, etc.)
  3. Client reconnects after several actions
  4. System identifies the last confirmed sequence point for the client
  5. Rollback state is sent with corresponding RNG state
  6. Client replays all actions, producing identical random results
  7. Client's game state is now synchronized with the server

Performance Considerations

Integrating RNG with rollbacks introduces performance considerations:

  1. RNG State Size: RNG state serialization should be compact

    • WyRand RNG state is typically only 8 bytes
    • Avoid large RNG algorithms for frequent serialization
  2. Selective Snapshots: Not every action needs RNG state preservation

    • Only save RNG state before randomized actions
    • Use sequence IDs to correlate RNG states with actions
  3. Batched Updates: Group randomized actions to minimize state snapshots

    • Example: When shuffling multiple permanents, capture RNG once
  4. Compressed History: Use a sliding window approach for history

    • Discard old RNG states when they're no longer needed
    • Keep only enough history for realistic rollback scenarios
  5. Optimized Serialization: Use efficient binary serialization

    • Consider custom serialization for RNG state if needed
    • Avoid JSON or other verbose formats for RNG state

Implementation Notes

  1. The RNG synchronization system must be initialized before the rollback system
  2. All random operations must use the synchronized RNG, never thread_rng() or other sources
  3. Player-specific operations should use player-specific RNG components
  4. RNG state should be included in regular network synchronization
  5. Client reconnection should always include RNG state restoration

Comprehensive Testing Guide for MTG Commander Online

This document serves as the central index for all testing documentation related to our networked MTG Commander implementation. It provides an overview of our testing strategy and links to detailed documentation for specific testing areas.

Core Principles

Our testing strategy for the online MTG Commander implementation is built on the following core principles:

  1. Comprehensive Coverage: Testing all aspects of the system, from individual components to full end-to-end gameplay
  2. Realism: Simulating real-world conditions, including varied network environments and player behaviors
  3. Automation: Maximizing the use of automated testing to enable frequent regression testing
  4. Game Rule Compliance: Ensuring the implementation adheres to all Commander format rules
  5. Security: Verifying that hidden information remains appropriately hidden
  6. Performance: Validating that the system functions well under various loads and conditions

Testing Documentation Structure

DocumentDescription
Core Testing StrategyOutlines the fundamental approach to testing the networking implementation
Advanced Testing StrategiesCovers specialized testing approaches for Commander-specific needs
Integration TestingDetails testing at the boundary between networking and game engine
Security TestingApproaches for testing information hiding and anti-cheat mechanisms

Testing Types

Unit Testing

Unit tests focus on individual components in isolation:

  • Networking protocol components
  • State synchronization mechanisms
  • Game rule implementation
  • Command processing

Integration Testing

Integration tests verify components work together correctly:

  • Networking and game state management
  • Client/server communication
  • Action validation and execution
  • Priority and turn handling

System Testing

System tests examine the complete system's functionality:

  • Full game scenarios
  • Multiple players
  • Complete turn cycles
  • Commander-specific rules

Network Simulation Testing

Tests under various network conditions:

  • High latency
  • Packet loss
  • Jitter
  • Bandwidth limitations
  • Server/client disconnection and reconnection

Test Implementation Guidance

When implementing tests, follow these guidelines:

  1. Test Isolation: Each test should run independently without relying on state from other tests
  2. Determinism: Tests should produce consistent results when run multiple times with the same inputs
  3. Clear Assertions: Use descriptive assertion messages that explain what is being tested and why it failed
  4. Comprehensive Verification: Verify all relevant aspects of state after actions, not just one element
  5. Cleanup: Tests should clean up after themselves to avoid interfering with other tests

Test Data Management

Standard test fixtures are available for:

  • Player configurations
  • Deck compositions
  • Board states
  • Game scenarios

Use the TestDataRepository to access these fixtures:

#![allow(unused)]
fn main() {
// Example of using test fixtures
#[test]
fn test_combat_interaction() {
    let mut app = setup_test_app();
    
    // Load a predefined mid-game state with creatures
    let test_state = TestDataRepository::load_fixture("mid_game_combat_state");
    setup_game_state(&mut app, &test_state);
    
    // Execute test
    // ...
}
}

Continuous Integration

Our CI pipeline automatically runs the following test suites:

  1. Unit Tests: On every push and pull request
  2. Integration Tests: On every push and pull request
  3. System Tests: On every push to main or develop branches
  4. Security Tests: Nightly on develop branch
  5. Network Simulation Tests: Nightly on develop branch

Test results are available in the CI dashboard, including:

  • Test pass/fail status
  • Performance benchmarks
  • Coverage reports
  • Network simulation metrics

Local Testing Workflow

To run tests locally:

# Run unit tests
cargo test networking::unit

# Run integration tests
cargo test networking::integration

# Run system tests
cargo test networking::system

# Run security tests
cargo test networking::security

# Run network simulation tests
cargo test networking::simulation

For more detailed output:

cargo test networking::integration -- --nocapture --test-threads=1

Additional Testing Resources

Contributing New Tests

When adding new tests:

  1. Identify the appropriate category for your test
  2. Follow the existing naming conventions
  3. Add detailed comments explaining the test purpose and expected behavior
  4. Update test documentation if adding new test categories
  5. Ensure tests run within a reasonable timeframe

By following this comprehensive testing strategy, we can ensure our networked MTG Commander implementation is robust, performant, and faithful to the rules of the game. Our testing suite provides confidence that the game will work correctly across a variety of real-world conditions and player interactions.

Networking Testing Documentation

This section provides comprehensive documentation on testing methodologies for our networked MTG Commander game engine.

Testing Overview

Testing networked applications presents unique challenges due to:

  1. Variable Network Conditions: Latency, packet loss, and disconnections
  2. State Synchronization: Ensuring all clients see the same game state
  3. Randomization Consistency: Maintaining deterministic behavior across network boundaries
  4. Security Concerns: Preventing cheating and unauthorized access

Our testing approach addresses these challenges through a multi-layered strategy, combining unit tests, integration tests, and end-to-end tests with specialized tools for network simulation.

Testing Categories

Unit Tests

Unit tests verify individual components and systems in isolation:

Integration Tests

Integration tests verify that multiple components work together correctly:

End-to-End Tests

End-to-end tests verify complete game scenarios from start to finish:

Performance Tests

Performance tests measure the efficiency and scalability of our networking code:

Security Tests

Security tests verify that our game is resistant to cheating and unauthorized access:

Test Implementation Guide

When implementing tests for our networked MTG Commander game, follow these guidelines:

  1. Test Each Layer: Test network communication, state synchronization, and game logic separately
  2. Simulate Real Conditions: Use network simulators to test under realistic conditions
  3. Automation: Automate as many tests as possible for continuous integration
  4. Determinism: Ensure tests are deterministic and repeatable
  5. RNG Testing: Pay special attention to randomized game actions

Testing Tools

Our testing infrastructure includes these specialized tools:

  1. Network Simulators: Tools to simulate various network conditions
  2. Test Harnesses: Specialized test environments for network testing
  3. RNG Test Utilities: Tools for verifying random number determinism
  4. Benchmarking Tools: Performance measurement utilities

Key Test Scenarios

Ensure these critical scenarios are thoroughly tested:

  1. Client Connection/Disconnection: Test proper handling of clients joining and leaving
  2. State Synchronization: Verify all clients see the same game state
  3. Randomized Actions: Test that shuffling, coin flips, etc. are deterministic
  4. Network Disruption: Test recovery after connection issues
  5. Latency Compensation: Test playability under various latency conditions

Testing RNG with Replicon

Our new approach using bevy_replicon for RNG state management requires specialized testing:

Test Fixtures and Harnesses

We provide several test fixtures to simplify test implementation:


For more detailed information on specific testing areas, refer to the corresponding documentation links above.

Networking Testing Overview

This document provides a comprehensive overview of the testing approach for the Rummage MTG Commander game engine's networking functionality.

Table of Contents

  1. Introduction
  2. Testing Principles
  3. Testing Levels
  4. Test Fixtures
  5. Network Simulation
  6. Automation Approach

Introduction

Networking code is inherently complex due to its asynchronous nature, potential for race conditions, and sensitivity to network conditions. Our testing approach is designed to address these challenges by employing a combination of unit tests, integration tests, and end-to-end tests, with a focus on simulating real-world network conditions.

Testing Principles

Our networking testing follows these core principles:

  1. Deterministic Tests: Tests should be repeatable and produce the same results given the same inputs
  2. Isolation: Individual tests should run independently without relying on state from other tests
  3. Real-World Conditions: Tests should simulate various network conditions including latency, packet loss, and disconnections
  4. Comprehensive Coverage: Tests should cover all networking components and their interactions
  5. Performance Validation: Tests should validate that networking performs adequately under expected loads

Testing Levels

Unit Tests

Unit tests focus on individual networking components in isolation:

#![allow(unused)]
fn main() {
#[test]
fn test_message_serialization() {
    // Create a test message
    let message = NetworkMessage::GameAction {
        sequence_id: 123,
        player_id: 456,
        action: GameAction::DrawCard { player_id: 456, count: 1 },
    };
    
    // Serialize the message
    let serialized = bincode::serialize(&message).expect("Serialization failed");
    
    // Deserialize the message
    let deserialized: NetworkMessage = bincode::deserialize(&serialized).expect("Deserialization failed");
    
    // Verify the deserialized message matches the original
    assert_eq!(message, deserialized);
}
}

Integration Tests

Integration tests verify that multiple components work together correctly:

#![allow(unused)]
fn main() {
#[test]
fn test_client_server_connection() {
    // Set up a server
    let mut server_app = App::new();
    server_app.add_plugins(MinimalPlugins)
        .add_plugins(RepliconServerPlugin::default());
    
    // Set up a client
    let mut client_app = App::new();
    client_app.add_plugins(MinimalPlugins)
        .add_plugins(RepliconClientPlugin::default());
    
    // Start the server
    server_app.world.resource_mut::<RepliconServer>()
        .start_endpoint(ServerEndpoint::new(8080));
    
    // Connect the client
    client_app.world.resource_mut::<RepliconClient>()
        .connect_endpoint(ClientEndpoint::new("127.0.0.1", 8080));
    
    // Run updates to establish connection
    for _ in 0..10 {
        server_app.update();
        client_app.update();
    }
    
    // Verify the client is connected
    let client = client_app.world.resource::<RepliconClient>();
    assert!(client.is_connected());
}
}

End-to-End Tests

End-to-end tests verify complete game scenarios:

#![allow(unused)]
fn main() {
#[test]
fn test_multiplayer_game_flow() {
    // Set up a server with a game
    let mut server_app = setup_server_with_game();
    
    // Set up clients for multiple players
    let mut client_apps = vec![
        setup_client_app(0),
        setup_client_app(1),
        setup_client_app(2),
        setup_client_app(3),
    ];
    
    // Connect all clients
    connect_all_clients(&mut server_app, &mut client_apps);
    
    // Run a full game turn cycle
    run_game_turn_cycle(&mut server_app, &mut client_apps);
    
    // Verify game state is consistent across all clients
    verify_consistent_game_state(&server_app, &client_apps);
}
}

Test Fixtures

Basic Network Test Fixture

#![allow(unused)]
fn main() {
/// Sets up a standard network test environment with server and clients
pub fn setup_network_test(app: &mut App, is_server: bool, is_client: bool) {
    // Add required plugins
    app.add_plugins(MinimalPlugins);
    
    // Add either server or client plugins
    if is_server {
        app.add_plugins(RepliconServerPlugin::default());
    }
    
    if is_client {
        app.add_plugins(RepliconClientPlugin::default());
    }
    
    // Add networking resources
    app.init_resource::<NetworkConfig>()
        .init_resource::<ConnectionStatus>();
    
    // Add core networking systems
    app.add_systems(Update, network_connection_status_update);
}
}

Game State Test Fixture

#![allow(unused)]
fn main() {
/// Sets up a test environment with a standard game state
pub fn setup_test_game_state(app: &mut App) {
    // Add game state
    app.init_resource::<GameState>();
    
    // Set up players
    let player_entities = spawn_test_players(app);
    
    // Set up initial game board
    setup_test_board_state(app, &player_entities);
    
    // Initialize game systems
    app.add_systems(Update, (
        update_game_state,
        process_game_actions,
        sync_game_state,
    ));
}
}

Network Simulation

To test under various network conditions, we use a network condition simulator:

#![allow(unused)]
fn main() {
/// Simulates network conditions for testing
pub struct NetworkConditionSimulator {
    /// Simulated latency in milliseconds
    pub latency: u32,
    /// Packet loss percentage (0-100)
    pub packet_loss: u8,
    /// Jitter in milliseconds
    pub jitter: u32,
    /// Bandwidth cap in KB/s
    pub bandwidth: u32,
}

impl NetworkConditionSimulator {
    /// Applies network conditions to a packet
    pub fn process_packet(&self, packet: &mut Packet) {
        // Apply packet loss
        if rand::random::<u8>() < self.packet_loss {
            packet.dropped = true;
            return;
        }
        
        // Apply latency with jitter
        let jitter_amount = if self.jitter > 0 {
            rand::thread_rng().gen_range(0..self.jitter)
        } else {
            0
        };
        
        packet.delay = Duration::from_millis((self.latency + jitter_amount) as u64);
        
        // Apply bandwidth limitation
        if self.bandwidth > 0 {
            packet.throttled = packet.size > self.bandwidth;
        }
    }
}
}

Automation Approach

Our testing automation strategy focuses on:

  1. Continuous Integration: All networking tests run on every PR and merge to main
  2. Matrix Testing: Tests run against multiple configurations (OS, Bevy version, etc.)
  3. Performance Benchmarks: Regular testing of networking performance metrics
  4. Stress Testing: Load tests to verify behavior under heavy usage
  5. Long-running Tests: Tests that run for extended periods to catch time-dependent issues

Key Test Scenarios

The following critical scenarios must pass for all networking changes:

  1. Connection Handling: Establishing connections, handling disconnections, and reconnections
  2. State Synchronization: Ensuring all clients see the same game state
  3. Latency Compensation: Verifying the game remains playable under various latency conditions
  4. Error Recovery: Testing recovery from network errors and disruptions
  5. Security: Validating that security measures work as expected

For more detailed testing information, see the RNG Synchronization Tests and Replicon RNG Tests documents.

Testing RNG Synchronization in Networked Gameplay

This document outlines testing strategies for ensuring deterministic and synchronized random number generation across network boundaries in our MTG Commander game engine.

Table of Contents

  1. Testing Goals
  2. Test Categories
  3. Test Fixtures
  4. Automated Tests
  5. Manual Testing
  6. Performance Considerations

Testing Goals

Testing RNG synchronization focuses on these key goals:

  1. Determinism: Verify that identical RNG seeds produce identical random sequences on all clients
  2. State Preservation: Ensure RNG state is properly serialized, transmitted, and restored
  3. Resilience: Test recovery from network disruptions or client reconnections
  4. Sequence Integrity: Confirm that game actions using randomness always produce the same results

Test Categories

Basic Determinism Tests

These tests verify that the underlying RNG components work deterministically:

#![allow(unused)]
fn main() {
#[test]
fn test_rng_determinism() {
    // Create two RNGs with the same seed
    let seed = 12345u64;
    let mut rng1 = WyRand::seed_from_u64(seed);
    let mut rng2 = WyRand::seed_from_u64(seed);
    
    // Generate sequences from both RNGs
    let sequence1: Vec<u32> = (0..100).map(|_| rng1.next_u32()).collect();
    let sequence2: Vec<u32> = (0..100).map(|_| rng2.next_u32()).collect();
    
    // Verify sequences are identical
    assert_eq!(sequence1, sequence2);
}
}

Serialization Tests

These tests verify that RNG state can be properly serialized and deserialized:

#![allow(unused)]
fn main() {
#[test]
fn test_rng_serialization() {
    // Create an RNG and use it to generate some values
    let mut original_rng = GlobalEntropy::<WyRand>::from_entropy();
    let original_values: Vec<u32> = (0..10).map(|_| original_rng.next_u32()).collect();
    
    // Serialize the RNG state
    let serialized_state = original_rng.try_serialize_state().expect("Failed to serialize RNG state");
    
    // Create a new RNG and deserialize the state into it
    let mut new_rng = GlobalEntropy::<WyRand>::from_entropy();
    new_rng.deserialize_state(&serialized_state).expect("Failed to deserialize RNG state");
    
    // Generate the same number of values from the new RNG
    let new_values: Vec<u32> = (0..10).map(|_| new_rng.next_u32()).collect();
    
    // The values should be the same, since the states were synchronized
    assert_eq!(original_values, new_values);
}
}

Network Transmission Tests

These tests verify that RNG state can be properly transmitted across the network:

#![allow(unused)]
fn main() {
#[test]
fn test_rng_network_transmission() {
    // Setup server and client apps
    let mut server_app = App::new();
    let mut client_app = App::new();
    
    // Configure apps for network testing
    setup_network_test(&mut server_app, true, false);
    setup_network_test(&mut client_app, false, true);
    
    // Run updates to establish connection
    for _ in 0..5 {
        server_app.update();
        client_app.update();
    }
    
    // Generate some random values on the server
    let server_values = {
        let mut rng = server_app.world.resource_mut::<GlobalEntropy<WyRand>>();
        (0..10).map(|_| rng.next_u32()).collect::<Vec<_>>()
    };
    
    // Serialize and send RNG state from server to client
    let serialized_state = {
        let rng = server_app.world.resource::<GlobalEntropy<WyRand>>();
        rng.try_serialize_state().expect("Failed to serialize RNG state")
    };
    
    // Simulate network transmission
    let rng_message = RngStateMessage {
        state: serialized_state,
        timestamp: server_app.world.resource::<Time>().elapsed_seconds(),
    };
    
    // Apply the RNG state to the client
    {
        let mut client_rng = client_app.world.resource_mut::<GlobalEntropy<WyRand>>();
        client_rng.deserialize_state(&rng_message.state).expect("Failed to deserialize RNG state");
    }
    
    // Generate the same number of values on the client
    let client_values = {
        let mut rng = client_app.world.resource_mut::<GlobalEntropy<WyRand>>();
        (0..10).map(|_| rng.next_u32()).collect::<Vec<_>>()
    };
    
    // Verify the values match
    assert_eq!(server_values, client_values);
}
}

Player-Specific RNG Tests

These tests verify that player-specific RNGs maintain determinism:

#![allow(unused)]
fn main() {
#[test]
fn test_player_rng_forking() {
    let mut app = App::new();
    app.add_plugins(MinimalPlugins)
        .add_plugins(EntropyPlugin::<WyRand>::default())
        .init_resource::<PlayerRegistry>();
    
    // Add test systems
    app.add_systems(Startup, setup_test_players)
        .add_systems(Update, fork_player_rngs);
    
    // Run the app to set up players and fork RNGs
    app.update();
    
    // Get player entities
    let player_registry = app.world.resource::<PlayerRegistry>();
    let player1 = player_registry.get_player(1);
    let player2 = player_registry.get_player(2);
    
    // Generate random sequences for each player
    let player1_values = generate_random_sequence(&mut app.world, player1);
    let player2_values = generate_random_sequence(&mut app.world, player2);
    
    // Values should be different because they were forked from the global RNG
    assert_ne!(player1_values, player2_values);
    
    // Save player RNG states
    save_player_rng_states(&mut app.world);
    
    // Create a new app and restore the states
    let mut new_app = App::new();
    // Configure new app
    // ...
    
    // Restore the player RNG states
    restore_player_rng_states(&mut new_app.world);
    
    // Generate new sequences
    let new_player1_values = generate_random_sequence(&mut new_app.world, player1);
    let new_player2_values = generate_random_sequence(&mut new_app.world, player2);
    
    // New sequences should match the original ones
    assert_eq!(player1_values, new_player1_values);
    assert_eq!(player2_values, new_player2_values);
}
}

Game Action Tests

These tests verify that game actions involving randomness produce consistent results:

#![allow(unused)]
fn main() {
#[test]
fn test_shuffle_library_determinism() {
    // Setup test environment
    let mut app = setup_multiplayer_test_app();
    
    // Create a deck with known order
    let cards = (1..53).collect::<Vec<_>>();
    
    // Setup player and library
    let player_entity = spawn_test_player(&mut app.world);
    let library_entity = spawn_test_library(&mut app.world, player_entity, cards.clone());
    
    // Seed the player's RNG
    seed_player_rng(&mut app.world, player_entity, 12345u64);
    
    // First shuffle
    app.world.send_event(ShuffleLibraryEvent { library_entity });
    app.update();
    
    // Get shuffled order
    let first_shuffle = get_library_order(&app.world, library_entity);
    
    // Reset library and RNG to original state
    reset_library(&mut app.world, library_entity, cards.clone());
    seed_player_rng(&mut app.world, player_entity, 12345u64);
    
    // Second shuffle
    app.world.send_event(ShuffleLibraryEvent { library_entity });
    app.update();
    
    // Get shuffled order
    let second_shuffle = get_library_order(&app.world, library_entity);
    
    // Both shuffles should result in the same order
    assert_eq!(first_shuffle, second_shuffle);
}
}

Test Fixtures

Common test fixtures for RNG testing:

#![allow(unused)]
fn main() {
/// Sets up a test environment with multiple clients
pub fn setup_multiplayer_rng_test() -> TestHarness {
    let mut harness = TestHarness::new();
    
    // Setup server
    harness.create_server_app();
    
    // Setup multiple clients
    for i in 0..4 {
        harness.create_client_app(i);
    }
    
    // Initialize RNG with a fixed seed
    harness.seed_global_rng(12345u64);
    
    // Connect clients to server
    harness.connect_all_clients();
    
    harness
}

/// Executes a randomized game action on all clients and verifies consistency
pub fn verify_random_action_consistency(harness: &mut TestHarness, action: RandomizedAction) {
    // Execute action on server
    harness.execute_on_server(action.clone());
    
    // Synchronize RNG state to clients
    harness.sync_rng_state();
    
    // Execute same action on all clients
    let results = harness.execute_on_all_clients(action);
    
    // All results should be identical
    let first_result = &results[0];
    for result in &results[1..] {
        assert_eq!(first_result, result);
    }
}
}

Automated Tests

Integration with CI Pipeline

Include these RNG synchronization tests in the CI pipeline:

# .github/workflows/rng-tests.yml
name: RNG Synchronization Tests

on:
  push:
    branches: [ main ]
    paths:
      - 'src/networking/rng/**'
      - 'src/game_engine/actions/**'
  pull_request:
    branches: [ main ]
    paths:
      - 'src/networking/rng/**'
      - 'src/game_engine/actions/**'

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - name: Setup Rust
        uses: actions-rs/toolchain@v1
        with:
          profile: minimal
          toolchain: stable
      - name: Run RNG tests
        run: cargo test --package rummage --lib networking::rng

Test Coverage Requirements

Aim for these coverage targets for RNG synchronization code:

  1. Line Coverage: At least 90%
  2. Branch Coverage: At least 85%
  3. Function Coverage: 100%

Manual Testing

Some aspects of RNG synchronization should be manually tested:

  1. Disconnection Recovery: Test that clients reconnecting receive correct RNG state
  2. High Latency Scenarios: Test with artificially high network latency
  3. Packet Loss: Test with simulated packet loss to verify recovery
  4. Cross-Platform Consistency: Verify RNG consistency between different operating systems

Performance Considerations

When testing RNG synchronization, monitor these performance metrics:

  1. Serialization Size: RNG state should be compact
  2. Synchronization Frequency: Balance consistency vs. network overhead
  3. CPU Overhead: Monitor CPU usage during RNG-heavy operations
  4. Memory Usage: Track memory usage when many player-specific RNGs are active

Documentation and Logging

Implement thorough logging for RNG synchronization to aid in debugging:

#![allow(unused)]
fn main() {
pub fn log_rng_sync(
    rng_state: Res<RngStateTracker>,
    client_id: Option<Res<ClientId>>,
) {
    if let Some(client_id) = client_id {
        info!(
            "RNG sync: Client {} received state of size {} bytes (timestamp: {})",
            client_id.0,
            rng_state.global_state.len(),
            rng_state.last_sync
        );
    } else {
        info!(
            "RNG sync: Server updated state of size {} bytes (timestamp: {})",
            rng_state.global_state.len(),
            rng_state.last_sync
        );
    }
}
}

Advanced Networking Testing Strategies for MTG Commander

This document expands upon our core testing strategies with advanced test scenarios and methodologies specifically designed for the complex requirements of an online MTG Commander implementation.

Table of Contents

  1. Commander-Specific Testing
  2. Long-Running Game Tests
  3. Concurrency and Race Condition Testing
  4. Snapshot and Replay Testing
  5. Fault Injection Testing
  6. Load and Stress Testing
  7. Cross-Platform Testing
  8. Automated Test Generation
  9. Property-Based Testing
  10. Test Coverage Analysis

Commander-Specific Testing

Commander as a format has unique rules and complex card interactions that require specialized testing.

Multiplayer Politics Testing

Test how the networking handles multi-player political interactions:

#![allow(unused)]
fn main() {
#[test]
fn test_multiplayer_politics() {
    let mut app = setup_multiplayer_test_app(4); // 4 players
    
    // Simulate player 1 offering a deal to player 2
    app.world.resource_mut::<TestController>()
        .execute_action(TestAction::ProposeDeal {
            proposer: 1,
            target: 2,
            proposal: "I won't attack you this turn if you don't attack me next turn".to_string()
        });
    
    app.update_n_times(5); // Allow time for messages to propagate
    
    // Verify deal is visible to both players but hidden from others
    let client1 = get_client_view(&app, 1);
    let client2 = get_client_view(&app, 2);
    let client3 = get_client_view(&app, 3);
    let client4 = get_client_view(&app, 4);
    
    assert!(client1.can_see_proposal(proposal_id));
    assert!(client2.can_see_proposal(proposal_id));
    assert!(!client3.can_see_proposal(proposal_id));
    assert!(!client4.can_see_proposal(proposal_id));
    
    // Player 2 accepts deal
    app.world.resource_mut::<TestController>()
        .execute_action(TestAction::RespondToDeal {
            player: 2,
            proposal_id,
            response: ProposalResponse::Accept
        });
    
    app.update_n_times(5);
    
    // Verify deal acceptance is recorded and visible
    assert!(client1.proposals.contains_accepted(proposal_id));
    assert!(client2.proposals.contains_accepted(proposal_id));
}
}

Commander Damage Testing

Verify correct tracking and synchronization of commander damage:

#![allow(unused)]
fn main() {
#[test]
fn test_commander_damage_tracking() {
    let mut app = setup_multiplayer_test_app(4);
    
    // Setup commanders for all players
    for player_id in 1..=4 {
        app.world.resource_mut::<TestController>()
            .setup_commander(player_id, commander_card_ids[player_id-1]);
    }
    
    // Player 1's commander deals damage to player 2
    app.world.resource_mut::<TestController>()
        .execute_action(TestAction::AttackWithCommander {
            attacker: 1,
            defender: 2,
            damage: 5
        });
    
    app.update_n_times(10);
    
    // Verify commander damage is tracked and synchronized
    for player_id in 1..=4 {
        let client = get_client_view(&app, player_id);
        
        // All players should see same commander damage value
        assert_eq!(
            client.commander_damage_received[2][1], 
            5,
            "Player {} sees incorrect commander damage", 
            player_id
        );
    }
    
    // Second attack with commander
    app.world.resource_mut::<TestController>()
        .execute_action(TestAction::AttackWithCommander {
            attacker: 1,
            defender: 2,
            damage: 6
        });
    
    app.update_n_times(10);
    
    // Check for player death from 21+ commander damage
    for player_id in 1..=4 {
        let client = get_client_view(&app, player_id);
        
        // Commander damage should be cumulative
        assert_eq!(client.commander_damage_received[2][1], 11);
        
        // Player 2 is still alive (under 21 damage)
        assert!(client.players[2].is_alive);
    }
    
    // Final attack for lethal commander damage
    app.world.resource_mut::<TestController>()
        .execute_action(TestAction::AttackWithCommander {
            attacker: 1,
            defender: 2,
            damage: 10
        });
    
    app.update_n_times(10);
    
    // Verify player death and synchronization
    for player_id in 1..=4 {
        let client = get_client_view(&app, player_id);
        
        // Player 2 should be dead from commander damage
        assert_eq!(client.commander_damage_received[2][1], 21);
        assert!(!client.players[2].is_alive);
        assert_eq!(
            client.players[2].death_reason, 
            DeathReason::CommanderDamage(1)
        );
    }
}
}

Long-Running Game Tests

MTG Commander games can be lengthy. We need to test stability and synchronization over extended play sessions.

#![allow(unused)]
fn main() {
#[test]
fn test_long_running_game() {
    let mut app = setup_multiplayer_test_app(4);
    
    // Configure for extended test
    app.insert_resource(LongGameSimulation {
        turns_to_simulate: 20,
        actions_per_turn: 30,
        complexity_factor: 0.8, // Higher values = more complex actions
    });
    
    // Run the long game simulation
    app.add_systems(Update, long_game_simulation);
    
    // Run enough updates for the full simulation
    // (20 turns * 30 actions * 5 updates per action)
    app.update_n_times(20 * 30 * 5);
    
    // Assess results
    let metrics = app.world.resource::<GameMetrics>();
    
    // Check key reliability metrics
    assert!(metrics.desync_events == 0, "Game had state desynchronization events");
    assert!(metrics.error_count < 5, "Too many errors during extended play");
    assert!(metrics.network_bandwidth_average < MAX_BANDWIDTH_TARGET);
    
    // Verify final game state consistency
    verify_game_state_consistency(&app);
}
}

Concurrency and Race Condition Testing

Test for race conditions and concurrency issues that might occur when multiple network events arrive simultaneously:

#![allow(unused)]
fn main() {
#[test]
fn test_simultaneous_actions() {
    let mut app = setup_multiplayer_test_app(4);
    
    // Create a scenario where multiple clients try to act at the exact same time
    let test_controller = app.world.resource_mut::<TestController>();
    
    // Configure test to deliver these actions "simultaneously" to the server
    test_controller.queue_simultaneous_actions(vec![
        (1, TestAction::CastSpell { card_id: 101, targets: vec![201] }),
        (2, TestAction::ActivateAbility { card_id: 202, ability_id: 1 }),
        (3, TestAction::PlayLand { card_id: 303 }),
    ]);
    
    // Process the simultaneous actions
    app.update();
    
    // Verify the server handled them in a deterministic order
    // and all clients ended up with the same state
    let server_state = extract_server_game_state(&app);
    
    for client_id in 1..=4 {
        let client_state = extract_client_game_state(&app, client_id);
        
        assert_eq!(
            server_state.action_history,
            client_state.action_history,
            "Client {} has different action order from server", 
            client_id
        );
    }
}
}

Snapshot and Replay Testing

Implement snapshot testing to capture and replay complex game states to ensure deterministic behavior:

#![allow(unused)]
fn main() {
#[test]
fn test_game_state_snapshot_restore() {
    let mut app = setup_multiplayer_test_app(4);
    
    // Play several turns to reach a complex game state
    simulate_game_turns(&mut app, 5);
    
    // Take a snapshot of the current game state
    let snapshot = create_game_snapshot(&app);
    
    // Save snapshot to disk for future tests
    save_snapshot(&snapshot, "complex_board_state.snapshot");
    
    // Create a fresh app and restore the snapshot
    let mut restored_app = setup_multiplayer_test_app(4);
    restore_game_snapshot(&mut restored_app, &snapshot);
    
    // Verify restored state matches original
    let original_state = extract_game_state(&app);
    let restored_state = extract_game_state(&restored_app);
    
    assert_eq!(original_state, restored_state, "Restored state does not match original");
    
    // Execute identical actions on both instances
    let test_actions = generate_test_actions();
    
    for action in &test_actions {
        execute_action(&mut app, action);
        execute_action(&mut restored_app, action);
    }
    
    // Verify both instances end in identical states
    let final_original_state = extract_game_state(&app);
    let final_restored_state = extract_game_state(&restored_app);
    
    assert_eq!(
        final_original_state, 
        final_restored_state,
        "Divergent states after identical action sequences"
    );
}
}

Fault Injection Testing

Systematically introduce faults to verify the system's resilience:

#![allow(unused)]
fn main() {
#[test]
fn test_client_disconnect_reconnect() {
    let mut app = setup_multiplayer_test_app(4);
    
    // Play several turns
    simulate_game_turns(&mut app, 3);
    
    // Force disconnect client 2
    app.world.resource_mut::<NetworkController>()
        .disconnect_client(2);
    
    // Continue game for a turn
    simulate_game_turns(&mut app, 1);
    
    // Snapshot game state before reconnection
    let state_before_reconnect = extract_game_state(&app);
    
    // Reconnect client 2
    app.world.resource_mut::<NetworkController>()
        .reconnect_client(2);
    
    // Allow time for state synchronization
    app.update_n_times(20);
    
    // Verify reconnected client has correct state
    let client2_state = extract_client_game_state(&app, 2);
    let server_state = extract_server_game_state(&app);
    
    assert_eq!(
        server_state.public_state,
        client2_state.public_state,
        "Reconnected client failed to synchronize state"
    );
    
    // Continue play and verify client 2 can take actions
    app.world.resource_mut::<TestController>()
        .execute_action(TestAction::PlayCard {
            player_id: 2,
            card_id: 123
        });
    
    app.update_n_times(5);
    
    // Verify action was processed
    let updated_state = extract_game_state(&app);
    assert!(updated_state.contains_played_card(2, 123));
}
}

Load and Stress Testing

Test how the system performs under high load:

#![allow(unused)]
fn main() {
#[test]
fn test_high_action_throughput() {
    let mut app = setup_multiplayer_test_app(4);
    
    // Configure test with high action frequency
    app.insert_resource(ActionThroughputTest {
        actions_per_second: 50,
        test_duration_seconds: 30,
    });
    
    // Run stress test
    app.add_systems(Update, high_throughput_test);
    app.update_n_times(30 * 60); // 30 seconds at 60 fps
    
    // Collect performance metrics
    let metrics = app.world.resource::<PerformanceMetrics>();
    
    // Verify system maintained performance under load
    assert!(metrics.action_processing_success_rate > 0.95); // >95% successful
    assert!(metrics.average_frame_time < 16.67); // Maintain 60fps (16.67ms)
    assert!(metrics.network_bandwidth_max < MAX_BANDWIDTH_LIMIT);
    
    // Verify game state remained consistent despite high throughput
    verify_game_state_consistency(&app);
}
}

Cross-Platform Testing

Test networking across different platforms and configurations:

#![allow(unused)]
fn main() {
// This would be implemented in CI pipeline to test different combinations
fn test_cross_platform() {
    // Test matrix for different platforms/configurations
    let platforms = ["Windows", "MacOS", "Linux"];
    let network_conditions = [
        NetworkCondition::Ideal,
        NetworkCondition::Home,
        NetworkCondition::Mobile,
    ];
    
    for server_platform in &platforms {
        for client_platform in &platforms {
            for &network_condition in &network_conditions {
                // Create test configuration
                let test_config = CrossPlatformTest {
                    server_platform: server_platform.to_string(),
                    client_platform: client_platform.to_string(),
                    network_condition,
                };
                
                // This would trigger tests on CI infrastructure
                run_cross_platform_test(test_config);
            }
        }
    }
}
}

Automated Test Generation

Implement systems to automatically generate test scenarios:

#![allow(unused)]
fn main() {
#[test]
fn test_generated_scenarios() {
    let mut app = setup_multiplayer_test_app(4);
    
    // Generate random but valid test scenarios
    let scenario_generator = TestScenarioGenerator::new()
        .with_seed(12345) // For reproducibility
        .with_complexity(TestComplexity::High)
        .with_game_progress(GameProgress::MidGame)
        .with_focus(TestFocus::ComplexBoardStates);
    
    // Generate 10 different test scenarios
    for _ in 0..10 {
        let scenario = scenario_generator.generate();
        
        // Set up the test scenario
        setup_test_scenario(&mut app, &scenario);
        
        // Run the test actions
        for action in scenario.actions {
            app.world.resource_mut::<TestController>().execute_action(action);
            app.update_n_times(5);
        }
        
        // Verify game state is consistent and valid
        verify_game_state_consistency(&app);
        verify_game_rules_not_violated(&app);
        
        // Reset for next scenario
        app.world.resource_mut::<TestController>().reset_game();
    }
}
}

Property-Based Testing

Use property-based testing to verify game invariants hold under various conditions:

#![allow(unused)]
fn main() {
#[test]
fn test_game_invariants() {
    // Setup property testing
    proptest::proptest!(|(
        num_players in 2..=4usize,
        game_turns in 1..20usize,
        actions_per_turn in 1..30usize,
        network_latency_ms in 0..500u64,
        packet_loss_percent in 0.0..15.0f32,
    )| {
        // Create test app with the specified conditions
        let mut app = setup_multiplayer_test_app(num_players);
        
        // Configure network conditions
        app.insert_resource(NetworkSimulation {
            latency: Some(network_latency_ms),
            packet_loss: Some(packet_loss_percent / 100.0),
            jitter: Some(network_latency_ms / 5),
        });
        
        // Run simulation
        simulate_game_with_params(&mut app, game_turns, actions_per_turn);
        
        // Check invariants that should always hold true
        let game_state = extract_game_state(&app);
        
        // Invariant 1: All players have exactly 1 commander (if alive)
        for player_id in 1..=num_players {
            let player = &game_state.players[player_id-1];
            if player.is_alive {
                assert_eq!(player.commanders.len(), 1);
            }
        }
        
        // Invariant 2: Total cards in game remains constant
        assert_eq!(
            game_state.total_cards_in_game,
            STARTING_CARD_COUNT * num_players
        );
        
        // Invariant 3: No player has negative life
        for player in &game_state.players {
            assert!(player.life >= 0);
        }
        
        // Invariant 4: All clients have consistent public information
        verify_public_state_consistency(&app);
    });
}
}

Test Coverage Analysis

Implement analysis tools to track test coverage specifically for networking and multiplayer aspects:

#![allow(unused)]
fn main() {
#[test]
fn generate_test_coverage_report() {
    // Run all networking tests with coverage tracking
    let results = run_tests_with_coverage("networking::");
    
    // Analyze coverage results
    let coverage = NetworkingCoverageAnalysis::from_results(&results);
    
    // Generate coverage report
    let report = coverage.generate_report();
    
    // Output report to file
    std::fs::write("networking_coverage.html", report).unwrap();
    
    // Verify minimum coverage thresholds
    assert!(coverage.message_types_covered > 0.95); // >95% of message types tested
    assert!(coverage.error_handlers_covered > 0.9); // >90% of error handlers tested
    assert!(coverage.synchronization_paths_covered > 0.9); // >90% of sync paths tested
    
    // Print coverage summary
    println!("Network message coverage: {:.1}%", coverage.message_types_covered * 100.0);
    println!("Error handler coverage: {:.1}%", coverage.error_handlers_covered * 100.0);
    println!("Sync path coverage: {:.1}%", coverage.synchronization_paths_covered * 100.0);
}
}

By implementing these advanced testing strategies along with our core testing approaches, we can ensure our MTG Commander online implementation is robust, performant, and provides a faithful and enjoyable multiplayer experience across various network conditions and edge cases.

Simulated Network Conditions

Testing Replicon Integration with RNG State Management

This document outlines specific test cases and methodologies for verifying the correct integration of bevy_replicon with our RNG state management system.

Table of Contents

  1. Introduction
  2. Test Environment Setup
  3. Unit Tests
  4. Integration Tests
  5. End-to-End Tests
  6. Performance Tests
  7. Debugging Failures
  8. Snapshot System Integration

Introduction

Testing the integration of bevy_replicon with RNG state management presents unique challenges:

  1. Network conditions are variable and unpredictable
  2. Randomized operations must be deterministic across network boundaries
  3. Rollbacks must preserve the exact RNG state
  4. Any deviations in RNG state can lead to unpredictable game outcomes

Our testing approach focuses on verifying determinism under various network conditions and ensuring proper recovery after disruptions.

Snapshot System Integration

For general information about testing the snapshot system, please refer to the centralized Snapshot System documentation:

The tests in this document specifically focus on the integration between the snapshot system, bevy_replicon, and RNG state management. When running these tests, it's important to also run the general snapshot system tests to ensure complete coverage.

Test Environment Setup

Local Network Testing Harness

#![allow(unused)]
fn main() {
/// Struct for testing replicon and RNG integration 
pub struct RepliconRngTestHarness {
    /// Server application
    pub server_app: App,
    /// Client applications (can have multiple)
    pub client_apps: Vec<App>,
    /// Network conditions simulator
    pub network_conditions: NetworkConditionSimulator,
    /// Test seed for deterministic behavior
    pub test_seed: u64,
}

impl RepliconRngTestHarness {
    /// Create a new test harness with the specified number of clients
    pub fn new(num_clients: usize) -> Self {
        let mut server_app = App::new();
        let mut client_apps = Vec::with_capacity(num_clients);
        
        // Setup server
        server_app.add_plugins(MinimalPlugins)
                  .add_plugins(DefaultRngPlugin)
                  .add_plugins(RepliconServerPlugin::default())
                  .add_plugin(RepliconRngRollbackPlugin)
                  .add_plugin(SnapshotPlugin); // Add the snapshot plugin
        
        // Setup RNG with specific seed for repeatability
        let test_seed = 12345u64;
        server_app.world.resource_mut::<GlobalEntropy<WyRand>>().seed_from_u64(test_seed);
        
        // Setup clients
        for _ in 0..num_clients {
            let mut client_app = App::new();
            client_app.add_plugins(MinimalPlugins)
                     .add_plugins(DefaultRngPlugin)
                     .add_plugins(RepliconClientPlugin::default())
                     .add_plugin(RepliconRngRollbackPlugin)
                     .add_plugin(SnapshotPlugin); // Add the snapshot plugin
                     
            // Each client gets the same seed
            client_app.world.resource_mut::<GlobalEntropy<WyRand>>().seed_from_u64(test_seed);
            
            client_apps.push(client_app);
        }
        
        Self {
            server_app,
            client_apps,
            network_conditions: NetworkConditionSimulator::default(),
            test_seed,
        }
    }
    
    /// Connect all clients to the server
    pub fn connect_all_clients(&mut self) {
        // Setup server to listen
        let server_port = 8080;
        self.server_app.world.resource_mut::<RepliconServer>()
            .start_endpoint(ServerEndpoint::new(server_port));
        
        // Connect clients
        for (i, client_app) in self.client_apps.iter_mut().enumerate() {
            client_app.world.resource_mut::<RepliconClient>()
                .connect_endpoint(ClientEndpoint::new("127.0.0.1", server_port));
        }
        
        // Update a few times to establish connections
        for _ in 0..10 {
            self.server_app.update();
            for client_app in &mut self.client_apps {
                client_app.update();
            }
        }
    }
    
    /// Simulate network disruption for a specific client
    pub fn simulate_disruption(&mut self, client_idx: usize, duration_ms: u64) {
        self.network_conditions.disconnect_client(client_idx, duration_ms);
    }
    
    /// Run a test with network conditions
    pub fn run_with_conditions<F>(&mut self, update_count: usize, test_fn: F) 
    where F: Fn(&mut Self, usize) {
        for i in 0..update_count {
            // Apply network conditions
            self.network_conditions.update(&mut self.client_apps);
            
            // Run server update
            self.server_app.update();
            
            // Run client updates
            for client_app in &mut self.client_apps {
                client_app.update();
            }
            
            // Call test function
            test_fn(self, i);
        }
    }
    
    /// Create a snapshot on the server
    pub fn create_server_snapshot(&mut self) -> Uuid {
        // Create a snapshot
        self.server_app.world.send_event(SnapshotEvent::Take);
        self.server_app.update();
        
        // Return the snapshot ID
        self.server_app.world.resource::<SnapshotRegistry>()
                             .most_recent()
                             .unwrap()
                             .id
    }
    
    /// Apply a snapshot on the server
    pub fn apply_server_snapshot(&mut self, snapshot_id: Uuid) {
        self.server_app.world.send_event(SnapshotEvent::Apply(snapshot_id));
        self.server_app.update();
    }
}

/// Simulates different network conditions
pub struct NetworkConditionSimulator {
    /// Client disconnection timers
    pub disconnection_timers: HashMap<usize, u64>,
    /// Packet loss percentages
    pub packet_loss_rates: HashMap<usize, f32>,
    /// Latency values
    pub latencies: HashMap<usize, u64>,
}

impl NetworkConditionSimulator {
    /// Disconnect a client for a duration
    pub fn disconnect_client(&mut self, client_idx: usize, duration_ms: u64) {
        self.disconnection_timers.insert(client_idx, duration_ms);
    }
    
    /// Apply network conditions to clients
    pub fn update(&mut self, client_apps: &mut [App]) {
        // Update disconnection timers and reconnect if needed
        let mut reconnect = Vec::new();
        for (client_idx, timer) in &mut self.disconnection_timers {
            if *timer <= 16 {
                reconnect.push(*client_idx);
            } else {
                *timer -= 16; // Assuming 60 FPS
            }
        }
        
        for client_idx in reconnect {
            self.disconnection_timers.remove(&client_idx);
        }
    }
}
}

Unit Tests

Testing RNG State Serialization and Deserialization

#![allow(unused)]
fn main() {
#[test]
fn test_rng_state_serialization() {
    // Create a test app
    let mut app = App::new();
    app.add_plugins(MinimalPlugins)
       .add_plugins(DefaultRngPlugin)
       .init_resource::<RngReplicationState>();
    
    // Setup global RNG with specific seed
    let test_seed = 12345u64;
    app.world.resource_mut::<GlobalEntropy<WyRand>>().seed_from_u64(test_seed);
    
    // Generate some random values and store them
    let original_values: Vec<u32> = {
        let mut rng = app.world.resource_mut::<GlobalEntropy<WyRand>>();
        (0..10).map(|_| rng.gen::<u32>()).collect()
    };
    
    // Capture the RNG state
    let mut rng_state = app.world.resource_mut::<RngReplicationState>();
    let global_rng = app.world.resource::<GlobalEntropy<WyRand>>();
    rng_state.global_state = global_rng.try_serialize_state().unwrap();
    
    // Create a new app with fresh RNG
    let mut new_app = App::new();
    new_app.add_plugins(MinimalPlugins)
           .add_plugins(DefaultRngPlugin);
    
    // Apply the saved state
    let mut new_global_rng = new_app.world.resource_mut::<GlobalEntropy<WyRand>>();
    new_global_rng.deserialize_state(&rng_state.global_state).unwrap();
    
    // Generate values from the new RNG
    let new_values: Vec<u32> = {
        let mut rng = new_app.world.resource_mut::<GlobalEntropy<WyRand>>();
        (0..10).map(|_| rng.gen::<u32>()).collect()
    };
    
    // Values should be different from the original sequence
    // because we captured the state after generating the original values
    assert_ne!(original_values, new_values);
    
    // Reset both RNGs to the same seed and generate sequences
    app.world.resource_mut::<GlobalEntropy<WyRand>>().seed_from_u64(test_seed);
    new_app.world.resource_mut::<GlobalEntropy<WyRand>>().seed_from_u64(test_seed);
    
    let reset_values1: Vec<u32> = {
        let mut rng = app.world.resource_mut::<GlobalEntropy<WyRand>>();
        (0..10).map(|_| rng.gen::<u32>()).collect()
    };
    
    let reset_values2: Vec<u32> = {
        let mut rng = new_app.world.resource_mut::<GlobalEntropy<WyRand>>();
        (0..10).map(|_| rng.gen::<u32>()).collect()
    };
    
    // Values should now be identical
    assert_eq!(reset_values1, reset_values2);
}
}

Testing Snapshot System with RNG State

#![allow(unused)]
fn main() {
#[test]
fn test_snapshot_preserves_rng_state() {
    // Create a test app with both the RNG and snapshot plugins
    let mut app = App::new();
    app.add_plugins(MinimalPlugins)
       .add_plugins(DefaultRngPlugin)
       .add_plugin(SnapshotPlugin)
       .add_plugin(RepliconRngRollbackPlugin)
       .init_resource::<SequenceTracker>();
    
    // Seed RNG
    let test_seed = 12345u64;
    app.world.resource_mut::<GlobalEntropy<WyRand>>().seed_from_u64(test_seed);
    
    // Create an entity with a marker component
    app.world.spawn((
        Snapshotable,
        RandomizedBehavior::default(),
        Transform::default(),
    ));
    
    // Generate some initial values and mark the entity as using them
    let initial_values: Vec<u32> = {
        let mut rng = app.world.resource_mut::<GlobalEntropy<WyRand>>();
        (0..5).map(|_| rng.gen::<u32>()).collect()
    };
    
    // Update sequence in the entity
    let mut entities = app.world.query::<&mut RandomizedBehavior>();
    for mut behavior in entities.iter_mut(&mut app.world) {
        behavior.last_rng_sequence = 1;
    }
    
    // Create a snapshot
    app.world.send_event(SnapshotEvent::Take);
    app.update();
    
    // Get the snapshot ID
    let snapshot_id = app.world.resource::<SnapshotRegistry>()
                           .most_recent()
                           .unwrap()
                           .id;
    
    // Generate more random values, changing the RNG state
    let _more_values: Vec<u32> = {
        let mut rng = app.world.resource_mut::<GlobalEntropy<WyRand>>();
        (0..10).map(|_| rng.gen::<u32>()).collect()
    };
    
    // Apply the snapshot to restore the game state including RNG
    app.world.send_event(SnapshotEvent::Apply(snapshot_id));
    app.update();
    
    // Generate new values from the restored RNG state
    let restored_values: Vec<u32> = {
        let mut rng = app.world.resource_mut::<GlobalEntropy<WyRand>>();
        (0..5).map(|_| rng.gen::<u32>()).collect()
    };
    
    // These values should be different from the initial values
    // since the RNG state has advanced, but they should be deterministic
    assert_ne!(initial_values, restored_values);
    
    // Create a new app and repeat the process to verify determinism
    let mut app2 = App::new();
    app2.add_plugins(MinimalPlugins)
        .add_plugins(DefaultRngPlugin)
        .add_plugin(SnapshotPlugin)
        .add_plugin(RepliconRngRollbackPlugin)
        .init_resource::<SequenceTracker>();
    
    // Seed RNG the same way
    app2.world.resource_mut::<GlobalEntropy<WyRand>>().seed_from_u64(test_seed);
    
    // Create the same entity
    app2.world.spawn((
        Snapshotable,
        RandomizedBehavior::default(),
        Transform::default(),
    ));
    
    // Generate the initial values exactly as before
    let _initial_values2: Vec<u32> = {
        let mut rng = app2.world.resource_mut::<GlobalEntropy<WyRand>>();
        (0..5).map(|_| rng.gen::<u32>()).collect()
    };
    
    // Update sequence in the entity
    let mut entities = app2.world.query::<&mut RandomizedBehavior>();
    for mut behavior in entities.iter_mut(&mut app2.world) {
        behavior.last_rng_sequence = 1;
    }
    
    // Create a snapshot
    app2.world.send_event(SnapshotEvent::Take);
    app2.update();
    
    // Get the snapshot ID
    let snapshot_id2 = app2.world.resource::<SnapshotRegistry>()
                             .most_recent()
                             .unwrap()
                             .id;
    
    // Generate more random values, changing the RNG state
    let _more_values2: Vec<u32> = {
        let mut rng = app2.world.resource_mut::<GlobalEntropy<WyRand>>();
        (0..10).map(|_| rng.gen::<u32>()).collect()
    };
    
    // Apply the snapshot to restore the game state including RNG
    app2.world.send_event(SnapshotEvent::Apply(snapshot_id2));
    app2.update();
    
    // Generate new values from the restored RNG state
    let restored_values2: Vec<u32> = {
        let mut rng = app2.world.resource_mut::<GlobalEntropy<WyRand>>();
        (0..5).map(|_| rng.gen::<u32>()).collect()
    };
    
    // The deterministically restored values should be identical in both runs
    assert_eq!(restored_values, restored_values2);
}
}

Testing Checkpoint Creation and Restoration

#![allow(unused)]
fn main() {
#[test]
fn test_checkpoint_creation_and_restoration() {
    // Create a test app with the plugin
    let mut app = App::new();
    app.add_plugins(MinimalPlugins)
       .add_plugins(DefaultRngPlugin)
       .add_plugin(RepliconRngRollbackPlugin)
       .init_resource::<SequenceTracker>();
    
    // Seed RNG
    let test_seed = 12345u64;
    app.world.resource_mut::<GlobalEntropy<WyRand>>().seed_from_u64(test_seed);
    
    // Generate some initial values
    let initial_values: Vec<u32> = {
        let mut rng = app.world.resource_mut::<GlobalEntropy<WyRand>>();
        (0..5).map(|_| rng.gen::<u32>()).collect()
    };
    
    // Create a checkpoint
    let checkpoint_sequence = 1;
    let mut checkpoints = app.world.resource_mut::<RollbackCheckpoints>();
    let rng_state = app.world.resource::<RngReplicationState>();
    let global_rng = app.world.resource::<GlobalEntropy<WyRand>>();
    
    let checkpoint = RollbackCheckpoint {
        sequence_id: checkpoint_sequence,
        timestamp: 0.0,
        global_rng_state: global_rng.try_serialize_state().unwrap(),
        player_rng_states: HashMap::new(),
        replicated_entities: Vec::new(),
    };
    
    checkpoints.checkpoints.insert(checkpoint_sequence, checkpoint);
    
    // Generate more values after checkpoint
    let post_checkpoint_values: Vec<u32> = {
        let mut rng = app.world.resource_mut::<GlobalEntropy<WyRand>>();
        (0..5).map(|_| rng.gen::<u32>()).collect()
    };
    
    // Restore from checkpoint
    let checkpoint = checkpoints.checkpoints.get(&checkpoint_sequence).unwrap();
    app.world.resource_mut::<GlobalEntropy<WyRand>>()
        .deserialize_state(&checkpoint.global_rng_state).unwrap();
    
    // Generate values after restoration
    let restored_values: Vec<u32> = {
        let mut rng = app.world.resource_mut::<GlobalEntropy<WyRand>>();
        (0..5).map(|_| rng.gen::<u32>()).collect()
    };
    
    // The restored values should match the post-checkpoint values
    assert_eq!(post_checkpoint_values, restored_values);
}
}

Integration Tests

Testing RNG Synchronization Between Server and Client

#![allow(unused)]
fn main() {
#[test]
fn test_server_client_rng_sync() {
    // Create test harness with 1 client
    let mut harness = RepliconRngTestHarness::new(1);
    harness.connect_all_clients();
    
    // Test variables
    let mut server_values = Vec::new();
    let mut client_values = Vec::new();
    
    // Run with updates
    harness.run_with_conditions(50, |harness, i| {
        if i == 10 {
            // Record server RNG values at update 10
            let mut rng = harness.server_app.world.resource_mut::<GlobalEntropy<WyRand>>();
            server_values = (0..5).map(|_| rng.gen::<u32>()).collect();
        }
        
        if i == 20 {
            // Record client RNG values at update 20
            // By now, RNG state should have been synced
            let mut rng = harness.client_apps[0].world.resource_mut::<GlobalEntropy<WyRand>>();
            client_values = (0..5).map(|_| rng.gen::<u32>()).collect();
            
            // Server will have advanced, get fresh set of values
            let mut rng = harness.server_app.world.resource_mut::<GlobalEntropy<WyRand>>();
            server_values = (0..5).map(|_| rng.gen::<u32>()).collect();
        }
    });
    
    // Client values should match server values from update 20
    assert_eq!(client_values, server_values);
}
}

Testing Rollback Due to Network Disruption

#![allow(unused)]
fn main() {
#[test]
fn test_rollback_after_disruption() {
    // Create test harness with 2 clients
    let mut harness = RepliconRngTestHarness::new(2);
    harness.connect_all_clients();
    
    // Setup game entities
    // ...
    
    // Run test with network disruption
    let mut pre_disruption_rng_values = Vec::new();
    let mut post_disruption_rng_values = Vec::new();
    let mut post_rollback_rng_values = Vec::new();
    
    harness.run_with_conditions(100, |harness, i| {
        if i == 20 {
            // Record RNG values before disruption
            let rng = harness.server_app.world.resource::<GlobalEntropy<WyRand>>();
            pre_disruption_rng_values = generate_test_random_values(rng, 10);
            
            // Simulate network disruption for client 0
            harness.simulate_disruption(0, 500); // 500ms disruption
        }
        
        if i == 40 {
            // Record RNG values after disruption
            let rng = harness.server_app.world.resource::<GlobalEntropy<WyRand>>();
            post_disruption_rng_values = generate_test_random_values(rng, 10);
        }
        
        if i == 60 {
            // By now rollback should have happened
            // Record RNG values after rollback
            let rng = harness.server_app.world.resource::<GlobalEntropy<WyRand>>();
            post_rollback_rng_values = generate_test_random_values(rng, 10);
            
            // Check that client 0 and client 1 have the same RNG state
            let rng0 = harness.client_apps[0].world.resource::<GlobalEntropy<WyRand>>();
            let rng1 = harness.client_apps[1].world.resource::<GlobalEntropy<WyRand>>();
            
            let client0_values = generate_test_random_values(rng0, 10);
            let client1_values = generate_test_random_values(rng1, 10);
            
            assert_eq!(client0_values, client1_values, "Clients should have same RNG state after rollback");
        }
    });
    
    // Verify behavior
    assert_ne!(pre_disruption_rng_values, post_disruption_rng_values, 
               "RNG values should change during normal operation");
    assert_eq!(post_rollback_rng_values, post_disruption_rng_values, 
               "After rollback, RNG sequences should match the checkpoint state");
}

/// Helper function to generate random values for testing
fn generate_test_random_values(rng: &GlobalEntropy<WyRand>, count: usize) -> Vec<u32> {
    let mut rng_clone = rng.clone();
    (0..count).map(|_| rng_clone.gen::<u32>()).collect()
}
}

End-to-End Tests

Testing Card Shuffling During Network Disruption

#![allow(unused)]
fn main() {
#[test]
fn test_card_shuffle_during_disruption() {
    // Setup test environment with card library
    let mut harness = RepliconRngTestHarness::new(2);
    harness.connect_all_clients();
    
    // Create players and libraries
    let server_player1 = setup_test_player(&mut harness.server_app.world, 1);
    let server_player2 = setup_test_player(&mut harness.server_app.world, 2);
    
    // Create identical card libraries
    let cards = (1..53).collect::<Vec<i32>>();
    let server_library1 = create_test_library(&mut harness.server_app.world, server_player1, cards.clone());
    let server_library2 = create_test_library(&mut harness.server_app.world, server_player2, cards.clone());
    
    // Initialize client players and libraries
    // ...
    
    // Shuffle results
    let mut server_shuffle_result1 = Vec::new();
    let mut server_shuffle_result2 = Vec::new();
    let mut client1_shuffle_result = Vec::new();
    let mut client2_shuffle_result = Vec::new();
    
    // Run test with network disruption during card shuffle
    harness.run_with_conditions(200, |harness, i| {
        if i == 50 {
            // Player 1 shuffles their library
            harness.server_app.world.send_event(ShuffleLibraryEvent { 
                library_entity: server_library1 
            });
        }
        
        if i == 60 {
            // Capture shuffle result
            server_shuffle_result1 = get_library_order(&harness.server_app.world, server_library1);
            
            // Cause network disruption
            harness.simulate_disruption(0, 1000);
        }
        
        if i == 80 {
            // Player 2 shuffles during disruption
            harness.server_app.world.send_event(ShuffleLibraryEvent { 
                library_entity: server_library2 
            });
        }
        
        if i == 100 {
            // Capture server-side shuffle results
            server_shuffle_result2 = get_library_order(&harness.server_app.world, server_library2);
        }
        
        if i == 150 {
            // By now, rollback and resynchronization should have occurred
            // Capture client-side shuffle results
            client1_shuffle_result = get_client_library_order(&harness.client_apps[0].world, 1);
            client2_shuffle_result = get_client_library_order(&harness.client_apps[1].world, 2);
        }
    });
    
    // Verify all libraries have the same shuffle result
    assert_eq!(server_shuffle_result1, client1_shuffle_result, 
               "Client 1 should have same shuffle result as server");
    assert_eq!(server_shuffle_result2, client2_shuffle_result, 
               "Client 2 should have same shuffle result as server");
}

/// Helper function to get library card order
fn get_library_order(world: &World, library_entity: Entity) -> Vec<i32> {
    if let Some(library) = world.get::<Library>(library_entity) {
        library.cards.clone()
    } else {
        Vec::new()
    }
}

/// Helper function to get client-side library order
fn get_client_library_order(client_world: &World, player_id: i32) -> Vec<i32> {
    // Find player by ID
    let player_entity = find_player_by_id(client_world, player_id);
    if player_entity.is_none() { return Vec::new(); }
    
    // Find library entity
    let library_entity = find_library_for_player(client_world, player_entity.unwrap());
    if library_entity.is_none() { return Vec::new(); }
    
    // Get library cards
    get_library_order(client_world, library_entity.unwrap())
}
}

Performance Tests

Testing RNG State Replication Bandwidth

#![allow(unused)]
fn main() {
#[test]
fn test_rng_replication_bandwidth() {
    // Create a test harness with multiple clients
    let mut harness = RepliconRngTestHarness::new(4);
    harness.connect_all_clients();
    
    // Setup bandwidth tracking
    let mut bandwidth_tracker = BandwidthTracker::new();
    
    // Run test with bandwidth monitoring
    harness.run_with_conditions(100, |harness, i| {
        if i % 10 == 0 {
            // Record bandwidth usage every 10 updates
            let server = harness.server_app.world.resource::<RepliconServer>();
            bandwidth_tracker.record_bandwidth(server.get_bandwidth_stats());
        }
    });
    
    // Analyze bandwidth results
    let results = bandwidth_tracker.analyze();
    
    // Ensure RNG state replication is within reasonable bounds
    assert!(results.avg_bandwidth_per_client < 1024, 
            "Average bandwidth should be less than 1KB per client");
    
    // Print results
    println!("Bandwidth results:");
    println!("  Average per client: {} bytes", results.avg_bandwidth_per_client);
    println!("  Peak: {} bytes", results.peak_bandwidth);
    println!("  Total: {} bytes", results.total_bandwidth);
}

/// Helper struct for tracking bandwidth usage
struct BandwidthTracker {
    samples: Vec<BandwidthSample>,
}

struct BandwidthSample {
    timestamp: f32,
    bytes_sent: usize,
    client_count: usize,
}

struct BandwidthResults {
    avg_bandwidth_per_client: f32,
    peak_bandwidth: usize,
    total_bandwidth: usize,
}

impl BandwidthTracker {
    fn new() -> Self {
        Self { samples: Vec::new() }
    }
    
    fn record_bandwidth(&mut self, stats: BandwidthStats) {
        self.samples.push(BandwidthSample {
            timestamp: stats.timestamp,
            bytes_sent: stats.bytes_sent,
            client_count: stats.client_count,
        });
    }
    
    fn analyze(&self) -> BandwidthResults {
        if self.samples.is_empty() {
            return BandwidthResults {
                avg_bandwidth_per_client: 0.0,
                peak_bandwidth: 0,
                total_bandwidth: 0,
            };
        }
        
        let total_bytes: usize = self.samples.iter().map(|s| s.bytes_sent).sum();
        let peak_bytes = self.samples.iter().map(|s| s.bytes_sent).max().unwrap_or(0);
        
        let client_samples: usize = self.samples.iter().map(|s| s.client_count).sum();
        let avg_per_client = if client_samples > 0 {
            total_bytes as f32 / client_samples as f32
        } else {
            0.0
        };
        
        BandwidthResults {
            avg_bandwidth_per_client: avg_per_client,
            peak_bandwidth: peak_bytes,
            total_bandwidth: total_bytes,
        }
    }
}

/// Mock struct to represent network bandwidth statistics
struct BandwidthStats {
    timestamp: f32,
    bytes_sent: usize,
    client_count: usize,
}
}

Debugging Failures

When tests fail, collect diagnostic information to aid debugging:

#![allow(unused)]
fn main() {
fn diagnose_rng_state_mismatch(
    server_rng: &GlobalEntropy<WyRand>,
    client_rng: &GlobalEntropy<WyRand>,
) -> String {
    // Serialize both RNG states
    let server_state = server_rng.try_serialize_state().unwrap_or_default();
    let client_state = client_rng.try_serialize_state().unwrap_or_default();
    
    // Generate test values from both
    let mut server_rng_clone = server_rng.clone();
    let mut client_rng_clone = client_rng.clone();
    
    let server_values: Vec<u32> = (0..5).map(|_| server_rng_clone.gen::<u32>()).collect();
    let client_values: Vec<u32> = (0..5).map(|_| client_rng_clone.gen::<u32>()).collect();
    
    let mut report = String::new();
    report.push_str("RNG State Mismatch Diagnostic:\n");
    report.push_str(&format!("Server state: {:?}\n", server_state));
    report.push_str(&format!("Client state: {:?}\n", client_state));
    report.push_str(&format!("Server values: {:?}\n", server_values));
    report.push_str(&format!("Client values: {:?}\n", client_values));
    
    report
}
}

These tests validate that our bevy_replicon integration with RNG state management works correctly under various conditions, ensuring deterministic behavior in our networked MTG Commander game.

Remember to run these tests:

  1. Regularly during development
  2. After any changes to networking code
  3. After any changes to RNG-dependent game logic
  4. As part of the CI/CD pipeline

Networking Integration Testing

This section covers the integration testing approach for the networking components of the MTG Commander game engine.

Overview

Integration testing for networking components ensures that different parts of the networking stack work together correctly, from low-level protocols to high-level gameplay synchronization.

Components

Strategy

Detailed strategy for integration testing of networking components, including:

  • Test environment setup
  • Integration test suite organization
  • Mocking and simulation approaches
  • Continuous integration workflow
  • Regression testing methodology
  • Performance benchmarking

Testing Scope

The integration testing covers:

  • Client-server communication
  • Peer-to-peer interactions
  • Protocol compatibility
  • State synchronization across clients
  • Error handling and recovery
  • Network condition simulation
  • Cross-platform compatibility

Integration with Other Testing

This testing approach integrates with:

Integration Testing: Networking and Game Engine

This document focuses on the critical integration testing needed between the networking layer and the core MTG Commander game engine. Ensuring these two complex systems work together seamlessly is essential for a robust online implementation.

Table of Contents

  1. Integration Test Goals
  2. Test Architecture
  3. State Synchronization Tests
  4. MTG-Specific Network Integration Tests
  5. End-to-End Gameplay Tests
  6. Test Harness Implementation
  7. Continuous Integration Strategy
  8. Automated Test Generation

Integration Test Goals

Integration testing for our networked MTG Commander implementation focuses on:

  1. Seamless Interaction: Verifying the networking code and game engine interact without errors
  2. Game State Integrity: Ensuring game state remains consistent across server and clients
  3. Rules Enforcement: Confirming game rules are correctly enforced in multiplayer contexts
  4. Performance: Measuring and validating performance under realistic gameplay conditions
  5. Robustness: Testing recovery from network disruptions and other failures

Test Architecture

Our integration test architecture follows a layered approach:

┌─────────────────────────────────────┐
│       End-to-End Gameplay Tests     │
├─────────────────────────────────────┤
│   MTG-Specific Network Integration  │
├─────────────────────────────────────┤
│      State Synchronization Tests    │
├─────────────────────────────────────┤
│  Component Integration Tests        │
└─────────────────────────────────────┘

Each test layer builds on the previous, starting with component integration and progressing to full gameplay scenarios.

Implementation Structure

#![allow(unused)]
fn main() {
// src/tests/integration/mod.rs
pub mod networking_game_integration {
    mod component_integration;
    mod state_synchronization;
    mod mtg_specific;
    mod end_to_end;
    
    pub use component_integration::*;
    pub use state_synchronization::*;
    pub use mtg_specific::*;
    pub use end_to_end::*;
}
}

State Synchronization Tests

These tests verify that game state is correctly synchronized between server and clients.

Game State Replication Test

#![allow(unused)]
fn main() {
#[test]
fn test_game_state_replication() {
    let mut app = setup_integration_test(4);
    
    // Configure initial game state
    app.world.resource_mut::<TestController>()
        .setup_game_state(TestGameState::MidGame);
    
    // Ensure all clients start with synchronized state
    app.update_n_times(10);
    
    // Execute a complex game action on the server
    app.world.resource_mut::<TestController>()
        .execute_server_action(ServerAction::ResolveComplexEffect {
            effect_id: 123,
            targets: vec![1, 2, 3],
            values: vec![5, 10, 15]
        });
    
    // Allow time for replication
    app.update_n_times(10);
    
    // Verify all clients received the updated state
    let server_state = extract_server_game_state(&app);
    
    for client_id in 1..=4 {
        let client_state = extract_client_game_state(&app, client_id);
        
        // Check public state is identical
        assert_eq!(
            server_state.public_state,
            client_state.public_state,
            "Client {} has inconsistent public state", 
            client_id
        );
        
        // Check player-specific private state
        assert_eq!(
            server_state.player_states[client_id].private_view,
            client_state.private_state,
            "Client {} has inconsistent private state", 
            client_id
        );
    }
}
}

Incremental Update Test

Verify that incremental updates are correctly applied:

#![allow(unused)]
fn main() {
#[test]
fn test_incremental_updates() {
    let mut app = setup_integration_test(4);
    
    // Track update messages sent
    let mut update_tracker = UpdateTracker::new();
    app.insert_resource(update_tracker);
    app.add_systems(Update, track_network_updates);
    
    // Execute sequence of small game actions
    let actions = [
        GameAction::DrawCard(1),
        GameAction::PlayLand(1, 101),
        GameAction::PassPriority(1),
        GameAction::PassPriority(2),
        GameAction::CastSpell(3, 302, vec![]),
    ];
    
    for action in &actions {
        app.world.resource_mut::<TestController>()
            .execute_action(action.clone());
        app.update_n_times(5);
    }
    
    // Retrieve update tracking data
    let update_tracker = app.world.resource::<UpdateTracker>();
    
    // Verify we sent incremental updates (not full state)
    assert!(update_tracker.full_state_updates < actions.len());
    assert!(update_tracker.incremental_updates > 0);
    
    // Verify game state is consistent
    verify_game_state_consistency(&app);
}
}

MTG-Specific Network Integration Tests

These tests focus on MTG-specific game mechanics and their network integration.

Priority Passing Test

#![allow(unused)]
fn main() {
#[test]
fn test_networked_priority_system() {
    let mut app = setup_integration_test(4);
    
    // Initialize game state for testing priority
    setup_priority_test_state(&mut app);
    
    // Track current priority holder
    let mut expected_priority = 1; // Start with player 1
    
    // Pass priority around the table
    for _ in 0..8 {  // 2 full cycles
        // Verify current priority
        let priority_system = app.world.resource::<PrioritySystem>();
        assert_eq!(
            priority_system.current_player, 
            expected_priority,
            "Incorrect priority holder"
        );
        
        // Current player passes priority
        app.world.resource_mut::<TestController>()
            .execute_action(GameAction::PassPriority(expected_priority));
        app.update_n_times(5);
        
        // Update expected priority (cycle 1->2->3->4->1)
        expected_priority = expected_priority % 4 + 1;
    }
    
    // Now test a player casting a spell with priority
    let priority_holder = expected_priority;
    
    // Execute cast spell action
    app.world.resource_mut::<TestController>()
        .execute_action(GameAction::CastSpell(
            priority_holder, 
            123, // Card ID
            vec![] // No targets
        ));
    app.update_n_times(5);
    
    // Verify spell was cast and on the stack
    let game_state = extract_server_game_state(&app);
    assert!(game_state.stack.contains_spell(123));
    
    // Verify priority passed to next player
    let priority_system = app.world.resource::<PrioritySystem>();
    assert_eq!(
        priority_system.current_player,
        priority_holder % 4 + 1,
        "Priority didn't pass after spell cast"
    );
}
}

Hidden Information Test

#![allow(unused)]
fn main() {
#[test]
fn test_networked_hidden_information() {
    let mut app = setup_integration_test(4);
    
    // Setup a player with cards in hand
    app.world.resource_mut::<TestController>()
        .setup_player_hand(1, vec![101, 102, 103]);
    
    app.update_n_times(5);
    
    // Verify only player 1 can see their hand contents
    for client_id in 1..=4 {
        let client_state = extract_client_game_state(&app, client_id);
        
        if client_id == 1 {
            // Player 1 should see all cards in their hand
            assert_eq!(client_state.player_hand.len(), 3);
            assert!(client_state.player_hand.contains(&101));
            assert!(client_state.player_hand.contains(&102));
            assert!(client_state.player_hand.contains(&103));
        } else {
            // Other players should only see card backs/count
            assert_eq!(client_state.opponents[0].hand_size, 3);
            assert!(!client_state.can_see_card(101));
            assert!(!client_state.can_see_card(102));
            assert!(!client_state.can_see_card(103));
        }
    }
    
    // Test revealing a card to all players
    app.world.resource_mut::<TestController>()
        .execute_action(GameAction::RevealCard(1, 102));
    
    app.update_n_times(5);
    
    // Verify all clients can now see the revealed card
    for client_id in 1..=4 {
        let client_state = extract_client_game_state(&app, client_id);
        assert!(client_state.can_see_card(102));
        
        // Other cards still hidden from opponents
        if client_id != 1 {
            assert!(!client_state.can_see_card(101));
            assert!(!client_state.can_see_card(103));
        }
    }
}
}

Stack Resolution Test

#![allow(unused)]
fn main() {
#[test]
fn test_networked_stack_resolution() {
    let mut app = setup_integration_test(4);
    
    // Setup initial game state with empty stack
    setup_stack_test_state(&mut app);
    
    // Player 1 casts a spell
    app.world.resource_mut::<TestController>()
        .execute_action(GameAction::CastSpell(1, 101, vec![]));
    app.update_n_times(5);
    
    // Player 2 responds with their own spell
    app.world.resource_mut::<TestController>()
        .execute_action(GameAction::CastSpell(2, 201, vec![101])); // Targeting first spell
    app.update_n_times(5);
    
    // Player 3 and 4 pass priority
    app.world.resource_mut::<TestController>()
        .execute_action(GameAction::PassPriority(3));
    app.update_n_times(5);
    
    app.world.resource_mut::<TestController>()
        .execute_action(GameAction::PassPriority(4));
    app.update_n_times(5);
    
    // Back to player 1, who passes
    app.world.resource_mut::<TestController>()
        .execute_action(GameAction::PassPriority(1));
    app.update_n_times(5);
    
    // Player 2 passes - this should resolve their spell
    app.world.resource_mut::<TestController>()
        .execute_action(GameAction::PassPriority(2));
    app.update_n_times(10); // More updates for resolution
    
    // Verify player 2's spell resolved
    let game_state = extract_server_game_state(&app);
    assert!(!game_state.stack.contains_spell(201));
    
    // Check if the effect was applied - in this case, counter player 1's spell
    assert!(!game_state.stack.contains_spell(101));
    
    // Verify all clients see the empty stack
    for client_id in 1..=4 {
        let client_state = extract_client_game_state(&app, client_id);
        assert_eq!(client_state.stack.spells.len(), 0);
    }
}
}

End-to-End Gameplay Tests

These tests simulate full gameplay scenarios to verify the complete integrated system.

Full Turn Cycle Test

#![allow(unused)]
fn main() {
#[test]
fn test_complete_turn_cycle() {
    let mut app = setup_integration_test(4);
    
    // Setup game state with predetermined decks and hands
    setup_deterministic_game_start(&mut app);
    
    // Execute a full turn cycle
    let turn_actions = generate_full_turn_actions(1); // For player 1's turn
    
    for action in turn_actions {
        app.world.resource_mut::<TestController>()
            .execute_action(action);
        app.update_n_times(5);
    }
    
    // Verify turn passed to next player
    let turn_system = app.world.resource::<TurnSystem>();
    assert_eq!(turn_system.active_player, 2);
    assert_eq!(turn_system.phase, Phase::Untap);
    
    // Verify all game state is consistent
    verify_game_state_consistency(&app);
    
    // Check that specific expected game actions occurred
    let game_state = extract_server_game_state(&app);
    assert!(game_state.turn_history.contains_action_by_player(1, ActionType::PlayLand));
    assert!(game_state.turn_history.contains_action_by_player(1, ActionType::CastSpell));
    
    // Verify expected cards moved zones correctly
    assert!(game_state.battlefield.contains_card_controlled_by(
        played_land_id, 1
    ));
    assert!(game_state.graveyard.contains_card(cast_spell_id));
}
}

Multiplayer Interaction Test

#![allow(unused)]
fn main() {
#[test]
fn test_multiplayer_interaction() {
    let mut app = setup_integration_test(4);
    
    // Setup mid-game state with interesting board presence
    setup_complex_board_state(&mut app);
    
    // Execute a complex multiplayer interaction:
    // Player 1 attacks player 3
    // Player 2 interferes with a combat trick
    // Player 4 counters player 2's spell
    
    let interaction_sequence = [
        // Player 1 declares attack
        GameAction::DeclareAttackers(1, vec![(creature_id_1, 3)]),
        
        // Priority passes to player 2 who casts combat trick
        GameAction::CastSpell(2, combat_trick_id, vec![creature_id_1]),
        
        // Player 3 passes
        GameAction::PassPriority(3),
        
        // Player 4 counters the combat trick
        GameAction::CastSpell(4, counterspell_id, vec![combat_trick_id]),
        
        // Priority passes around
        GameAction::PassPriority(1),
        GameAction::PassPriority(2),
        GameAction::PassPriority(3),
        GameAction::PassPriority(4),
        
        // Counterspell resolves, and damages goes through
    ];
    
    for action in &interaction_sequence {
        app.world.resource_mut::<TestController>()
            .execute_action(action.clone());
        app.update_n_times(5);
    }
    
    // Allow time for full resolution
    app.update_n_times(20);
    
    // Verify final state reflects expected outcome
    let game_state = extract_server_game_state(&app);
    
    // Combat trick should be countered
    assert!(game_state.graveyard.contains_card(combat_trick_id));
    assert!(game_state.graveyard.contains_card(counterspell_id));
    
    // Player 3 should have taken damage
    let player3_life = game_state.players[2].life;
    assert!(player3_life < STARTING_LIFE);
    
    // Verify all clients have consistent view of the outcome
    verify_game_state_consistency(&app);
}
}

Test Harness Implementation

Here's a sketch of the integration test harness structure:

#![allow(unused)]
fn main() {
pub struct IntegrationTestContext {
    pub app: App,
    pub network_monitor: NetworkMonitor,
    pub test_actions: Vec<TestAction>,
    pub verification_points: Vec<VerificationPoint>,
}

impl IntegrationTestContext {
    pub fn new(num_players: usize) -> Self {
        let mut app = App::new();
        
        // Add minimal plugins for testing
        app.add_plugins(MinimalPlugins)
            .add_plugins(RepliconServerPlugin::default())
            .add_plugins(RepliconClientPlugin::default());
            
        // Add MTG game systems
        app.add_plugins(MTGCommanderGamePlugin)
            .add_plugins(TestControllerPlugin);
        
        // Setup network monitoring
        let network_monitor = NetworkMonitor::new();
        app.insert_resource(network_monitor.clone());
        app.add_systems(Update, monitor_network_traffic);
        
        // Initialize game with specified players
        app.world.resource_mut::<TestController>()
            .initialize_game(num_players);
            
        Self {
            app,
            network_monitor,
            test_actions: Vec::new(),
            verification_points: Vec::new(),
        }
    }
    
    pub fn queue_action(&mut self, action: TestAction) {
        self.test_actions.push(action);
    }
    
    pub fn add_verification_point(&mut self, point: VerificationPoint) {
        self.verification_points.push(point);
    }
    
    pub fn run_test(&mut self) -> TestResult {
        // Execute all queued actions
        for action in &self.test_actions {
            self.app.world.resource_mut::<TestController>()
                .execute_action(action.clone());
            self.app.update_n_times(5);
        }
        
        // Check all verification points
        let mut results = Vec::new();
        for point in &self.verification_points {
            let result = verify_point(&self.app, point);
            results.push(result);
        }
        
        TestResult {
            passed: results.iter().all(|r| r.passed),
            verification_results: results,
            network_stats: self.network_monitor.get_stats(),
        }
    }
}
}

Continuous Integration Strategy

Our CI pipeline for integration testing uses a matrix approach:

# .github/workflows/integration-tests.yml
name: Integration Tests

on:
  push:
    branches: [ main, develop ]
  pull_request:
    branches: [ main, develop ]

jobs:
  integration-tests:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        test-category: [
          'component-integration',
          'state-synchronization',
          'mtg-specific',
          'end-to-end'
        ]
        num-players: [2, 3, 4]
      
    steps:
    - uses: actions/checkout@v2
    
    - name: Setup Rust
      uses: actions-rs/toolchain@v1
      with:
        toolchain: stable
        override: true
    
    - name: Build
      run: cargo build --verbose
    
    - name: Run integration tests
      run: |
        cargo test --verbose \
        networking_game_integration::${{ matrix.test-category }}::* \
        -- --test-threads=1 \
        --ignored \
        --exact \
        -Z unstable-options \
        --include-ignored \
        --env NUM_PLAYERS=${{ matrix.num-players }}

Automated Test Generation

We use property-based testing to generate valid game scenarios:

#![allow(unused)]
fn main() {
#[test]
fn test_generated_game_scenarios() {
    // Configure test generation parameters
    let generator_config = ScenarioGeneratorConfig {
        num_players: 4,
        min_turns: 3,
        max_turns: 10,
        complexity: TestComplexity::Medium,
        focus_areas: vec![
            FocusArea::Combat,
            FocusArea::SpellInteraction,
            FocusArea::CommanderMechanics,
        ],
    };
    
    let generator = ScenarioGenerator::new(generator_config);
    
    // Generate 5 different test scenarios
    for i in 0..5 {
        let scenario = generator.generate_scenario(i); // Use i as seed
        
        // Create fresh test context
        let mut test_context = IntegrationTestContext::new(scenario.num_players);
        
        // Setup initial state
        test_context.app.world.resource_mut::<TestController>()
            .setup_scenario(&scenario);
        
        // Queue all scenario actions
        for action in &scenario.actions {
            test_context.queue_action(action.clone());
        }
        
        // Add verification points from scenario
        for verification in &scenario.verifications {
            test_context.add_verification_point(verification.clone());
        }
        
        // Run the test
        let result = test_context.run_test();
        
        // Verify all checks passed
        assert!(
            result.passed,
            "Generated scenario {} failed: {:?}",
            i,
            result.failed_verifications()
        );
    }
}
}

This integration testing approach ensures that our networking code and MTG Commander game engine work together seamlessly, providing a robust foundation for online play. By combining structured test scenarios with automated generation, we can comprehensively test the complex interactions between game rules and network communication.

Networking Security Testing

This section covers the security testing approach for the networking components of the MTG Commander game engine.

Overview

Security testing for networking components ensures that the game's online features are protected against various threats and vulnerabilities, maintaining the integrity of gameplay and player data.

Components

Strategy

Detailed strategy for security testing of networking components, including:

  • Threat modeling approach
  • Vulnerability assessment methodology
  • Penetration testing procedures
  • Security audit processes
  • Compliance verification
  • Incident response planning

Testing Scope

The security testing covers:

  • Authentication and authorization
  • Data encryption and protection
  • Input validation and sanitization
  • Protection against common attack vectors
  • Session management security
  • Hidden information protection
  • Anti-cheat mechanism validation

Integration with Other Testing

This testing approach integrates with:

Security Testing for MTG Commander Online

This document outlines the security testing strategy for our MTG Commander online implementation, with a primary focus on protecting hidden information and preventing cheating in a distributed multiplayer environment.

Table of Contents

  1. Security Objectives
  2. Threat Model
  3. Hidden Information Protection
  4. Client Validation and Server Authority
  5. Anti-Cheat Testing
  6. Penetration Testing Methodology
  7. Fuzzing and Malformed Input Testing
  8. Session and Authentication Testing
  9. Continuous Security Testing

Security Objectives

The primary security objectives for our MTG Commander online implementation are:

  1. Information Confidentiality: Ensuring players only have access to information they're entitled to see
  2. Game State Integrity: Preventing unauthorized modification of game state
  3. Rules Enforcement: Ensuring all actions follow MTG rules, even against malicious clients
  4. Input Validation: Protecting against malicious inputs that could crash or exploit the game
  5. Fairness: Preventing any player from gaining unfair advantages through technical means

Threat Model

Our threat model considers the following potential adversaries and attack vectors:

Adversaries

  1. Curious Players: Regular players who might attempt to gain unfair advantages by viewing hidden information
  2. Cheaters: Players actively attempting to manipulate the game to win unfairly
  3. Griefers: Players attempting to disrupt gameplay for others without necessarily seeking to win
  4. Reverse Engineers: Technical users analyzing the client to understand and potentially exploit the protocol

Attack Vectors

  1. Client Modification: Altering the client to expose hidden information or enable illegal actions
  2. Network Traffic Analysis: Analyzing network traffic to reveal hidden information
  3. Protocol Exploitation: Sending malformed or unauthorized messages to the server
  4. Memory Examination: Using external tools to examine client memory for hidden information
  5. Timing Attacks: Using timing differences to infer hidden information

Hidden Information Protection

MTG has significant hidden information that must be protected:

  • Cards in hand
  • Library contents and order
  • Facedown cards
  • Opponent's choices for effects like scry

Testing Hidden Card Information

#![allow(unused)]
fn main() {
#[test]
fn test_hand_card_information_protection() {
    let mut app = setup_security_test_app(4);
    
    // Setup player 1 with known cards in hand
    let player1_hand = vec![101, 102, 103];
    app.world.resource_mut::<TestController>()
        .setup_player_hand(1, player1_hand.clone());
    
    // Allow state to replicate
    app.update_n_times(10);
    
    // Verify each client's visibility
    for client_id in 1..=4 {
        let client_view = extract_client_game_state(&app, client_id);
        
        if client_id == 1 {
            // Player 1 should see all details of their cards
            for card_id in &player1_hand {
                let card = client_view.get_card(*card_id);
                assert!(card.is_some());
                assert_eq!(card.unwrap().visibility_level, VisibilityLevel::FullInformation);
            }
        } else {
            // Other players should only see card backs
            let opponent_info = client_view.get_opponent_info(1);
            assert_eq!(opponent_info.hand_size, player1_hand.len());
            
            // Should not have full information about the cards
            for card_id in &player1_hand {
                let card = client_view.get_card(*card_id);
                assert!(
                    card.is_none() || card.unwrap().visibility_level == VisibilityLevel::CardBack,
                    "Client {} can see card {} when they shouldn't", 
                    client_id, 
                    card_id
                );
            }
        }
    }
}
}

Testing Revealed Card Information

#![allow(unused)]
fn main() {
#[test]
fn test_revealed_card_information() {
    let mut app = setup_security_test_app(4);
    
    // Setup player 1 with known cards in hand
    let player1_hand = vec![101, 102, 103];
    app.world.resource_mut::<TestController>()
        .setup_player_hand(1, player1_hand.clone());
    
    // Reveal a specific card to all players
    app.world.resource_mut::<TestController>()
        .execute_action(GameAction::RevealCard {
            player_id: 1,
            card_id: 102,
            reveal_to: RevealTarget::AllPlayers
        });
    
    app.update_n_times(10);
    
    // Verify correct card is revealed to all players
    for client_id in 1..=4 {
        let client_view = extract_client_game_state(&app, client_id);
        
        // All players should now see the revealed card
        let revealed_card = client_view.get_card(102);
        assert!(revealed_card.is_some());
        assert_eq!(
            revealed_card.unwrap().visibility_level, 
            VisibilityLevel::Revealed
        );
        
        // Other cards in hand should still be hidden
        if client_id != 1 {
            for &card_id in &[101, 103] {
                let card = client_view.get_card(card_id);
                assert!(
                    card.is_none() || card.unwrap().visibility_level == VisibilityLevel::CardBack,
                    "Client {} can see unrevealed card {} when they shouldn't", 
                    client_id, 
                    card_id
                );
            }
        }
    }
}
}

Testing Library Information Protection

#![allow(unused)]
fn main() {
#[test]
fn test_library_information_protection() {
    let mut app = setup_security_test_app(4);
    
    // Setup player 1 with known library
    let library_cards = (201..240).collect::<Vec<usize>>();
    app.world.resource_mut::<TestController>()
        .setup_player_library(1, library_cards.clone());
    
    app.update_n_times(10);
    
    // Verify library information protection
    for client_id in 1..=4 {
        let client_view = extract_client_game_state(&app, client_id);
        
        // All players should only see library size, not contents
        let player1_info = if client_id == 1 {
            client_view.get_player_info()
        } else {
            client_view.get_opponent_info(1)
        };
        
        assert_eq!(player1_info.library_size, library_cards.len());
        
        // No player should see library card details (even the owner)
        for &card_id in &library_cards {
            let card = client_view.get_card(card_id);
            
            // Card might be visible as existing, but contents should be hidden
            if card.is_some() {
                assert!(
                    card.unwrap().visibility_level == VisibilityLevel::CardBack ||
                    card.unwrap().zone == Zone::Library,
                    "Client {} can see library card {} contents when they shouldn't",
                    client_id,
                    card_id
                );
            }
        }
    }
}
}

Client Validation and Server Authority

The server must maintain authority over game state and validate all client actions.

Testing Illegal Action Prevention

#![allow(unused)]
fn main() {
#[test]
fn test_illegal_action_prevention() {
    let mut app = setup_security_test_app(4);
    
    // Setup game state with player 1 having some cards but not others
    app.world.resource_mut::<TestController>()
        .setup_player_hand(1, vec![101, 102]);
    
    app.update_n_times(10);
    
    // Test case 1: Try to play a card not in hand
    let illegal_action = SecurityTestAction::PlayNonExistentCard {
        player_id: 1,
        card_id: 999 // Card that doesn't exist
    };
    
    let result = app.world.resource_mut::<SecurityTestController>()
        .execute_illegal_action(illegal_action);
    
    app.update_n_times(10);
    
    // Verify action was rejected
    assert!(matches!(result, ActionResult::Rejected(rejection) if 
        rejection.reason == RejectionReason::CardNotFound));
    
    // Test case 2: Try to play opponent's card
    app.world.resource_mut::<TestController>()
        .setup_player_hand(2, vec![201]);
    
    app.update_n_times(10);
    
    let illegal_action = SecurityTestAction::PlayOpponentCard {
        player_id: 1,
        card_id: 201, // Card in player 2's hand
        target_player: 2
    };
    
    let result = app.world.resource_mut::<SecurityTestController>()
        .execute_illegal_action(illegal_action);
    
    app.update_n_times(10);
    
    // Verify action was rejected
    assert!(matches!(result, ActionResult::Rejected(rejection) if 
        rejection.reason == RejectionReason::NotYourCard));
    
    // Verify game state remains unchanged
    let game_state = extract_server_game_state(&app);
    assert!(game_state.players[0].hand.contains(&101));
    assert!(game_state.players[0].hand.contains(&102));
    assert!(game_state.players[1].hand.contains(&201));
}
}

Testing Play Sequence Enforcement

#![allow(unused)]
fn main() {
#[test]
fn test_out_of_sequence_play_prevention() {
    let mut app = setup_security_test_app(4);
    
    // Setup game state where it's player 1's turn, main phase
    app.world.resource_mut::<TestController>()
        .setup_game_phase(1, Phase::Main1);
    
    app.update_n_times(10);
    
    // Try to perform an action when it's not your turn
    let illegal_action = SecurityTestAction::ActOutOfTurn {
        player_id: 2, // Not the active player
        action_type: ActionType::PlayLand,
        card_id: 201
    };
    
    let result = app.world.resource_mut::<SecurityTestController>()
        .execute_illegal_action(illegal_action);
    
    app.update_n_times(10);
    
    // Verify action was rejected
    assert!(matches!(result, ActionResult::Rejected(rejection) if 
        rejection.reason == RejectionReason::NotYourTurn));
        
    // Try to declare attackers during main phase
    let illegal_action = SecurityTestAction::ActOutOfPhase {
        player_id: 1, // Correct player
        action_type: ActionType::DeclareAttackers,
    };
    
    let result = app.world.resource_mut::<SecurityTestController>()
        .execute_illegal_action(illegal_action);
    
    app.update_n_times(10);
    
    // Verify action was rejected
    assert!(matches!(result, ActionResult::Rejected(rejection) if 
        rejection.reason == RejectionReason::InvalidPhase));
}
}

Anti-Cheat Testing

Tests focused specifically on detecting and preventing common cheating methods.

Network Traffic Analysis

#![allow(unused)]
fn main() {
#[test]
fn test_network_traffic_does_not_leak_information() {
    let mut app = setup_security_test_app(4);
    
    // Setup player hands and library
    for player_id in 1..=4 {
        app.world.resource_mut::<TestController>()
            .setup_player_hand(player_id, vec![100 + player_id*10, 101 + player_id*10, 102 + player_id*10]);
            
        app.world.resource_mut::<TestController>()
            .setup_player_library(player_id, (200 + player_id*100..250 + player_id*100).collect());
    }
    
    // Start network traffic analyzer
    let mut traffic_analyzer = NetworkTrafficAnalyzer::new();
    app.insert_resource(traffic_analyzer.clone());
    app.add_systems(Update, analyze_network_traffic);
    
    // Run game actions that should generate network traffic
    let actions = [
        GameAction::DrawCard(1),
        GameAction::PlayLand(1, 110),
        GameAction::PassPriority(1),
        GameAction::PassTurn(1),
    ];
    
    for action in &actions {
        app.world.resource_mut::<TestController>()
            .execute_action(action.clone());
        app.update_n_times(5);
    }
    
    // Get traffic analysis results
    let traffic_results = app.world.resource::<NetworkTrafficAnalyzer>().get_results();
    
    // Verify no hidden information is leaked
    for &player_id in &[2, 3, 4] {
        // Check player hand cards aren't leaked to others
        for card_id in 100 + player_id*10..103 + player_id*10 {
            assert!(!traffic_results.contains_card_information(1, card_id),
                "Player 1's network traffic contains card {} from player {}'s hand",
                card_id, player_id);
        }
        
        // Check library card contents aren't leaked
        for card_id in 200 + player_id*100..250 + player_id*100 {
            assert!(!traffic_results.contains_card_information(1, card_id),
                "Player 1's network traffic contains card {} from player {}'s library",
                card_id, player_id);
        }
    }
}
}

Memory Examination Protection

#![allow(unused)]
fn main() {
#[test]
fn test_client_memory_protection() {
    // This would be a manual test in practice, documented here for completeness
    
    // Steps:
    // 1. Set up a game with known hidden information
    // 2. Attach memory examination tools to client process
    // 3. Scan memory for card identifiers or other game data
    // 4. Verify sensitive information is obfuscated or encrypted in memory
    
    // Implementation would vary based on platform and tooling
}
}

Penetration Testing Methodology

Structured approach to identifying security vulnerabilities:

#![allow(unused)]
fn main() {
fn perform_security_penetration_test() {
    // 1. Information Gathering
    let game_info = analyze_game_protocol();
    
    // 2. Threat Modeling
    let attack_vectors = identify_attack_vectors(game_info);
    
    // 3. Vulnerability Analysis
    let vulnerabilities = scan_for_vulnerabilities(attack_vectors);
    
    // 4. Exploitation
    for vulnerability in vulnerabilities {
        let exploit_result = attempt_exploit(vulnerability);
        
        if exploit_result.successful {
            record_security_issue(exploit_result);
        }
    }
    
    // 5. Post Exploitation
    analyze_exploit_impact();
    
    // 6. Reporting
    generate_security_report();
}
}

Example Penetration Test Scenario

#![allow(unused)]
fn main() {
#[test]
fn test_client_message_tampering() {
    let mut app = setup_security_test_app(4);
    
    // Setup initial game state
    app.world.resource_mut::<TestController>()
        .setup_standard_game_start();
    
    // Create a message tampering simulator
    let mut tampering_simulator = MessageTamperingSimulator::new();
    app.insert_resource(tampering_simulator);
    
    // Register message tampering scenarios
    app.world.resource_mut::<MessageTamperingSimulator>()
        .register_tampering_scenario(TamperingScenario::ModifyCardId {
            original_id: 101,
            modified_id: 999
        })
        .register_tampering_scenario(TamperingScenario::InjectAction {
            action: GameAction::DrawCard(1),
            injection_point: InjectionPoint::AfterPassPriority
        })
        .register_tampering_scenario(TamperingScenario::ModifyTargetPlayer {
            original_player: 2,
            modified_player: 1
        });
    
    // Run game with message tampering active
    let result = run_game_with_tampering(&mut app);
    
    // Verify server detected and rejected tampering
    assert_eq!(result.detected_tampering_count, 3);
    assert_eq!(result.successful_tampering_count, 0);
    
    // Verify game state integrity maintained
    assert!(result.game_state_integrity_maintained);
}
}

Fuzzing and Malformed Input Testing

Using automated fuzzing to identify input validation issues:

#![allow(unused)]
fn main() {
#[test]
fn test_protocol_fuzzing() {
    let mut app = setup_security_test_app(4);
    
    // Create fuzzer
    let fuzzer = ProtocolFuzzer::new()
        .with_seed(12345)
        .with_message_types(vec![
            MessageType::Action,
            MessageType::StateUpdate,
            MessageType::SyncRequest
        ])
        .with_mutation_rate(0.2);
    
    // Register fuzzer with app
    app.insert_resource(fuzzer);
    app.add_systems(Update, run_protocol_fuzzing);
    
    // Run for specified number of iterations
    for _ in 0..1000 {
        app.update();
    }
    
    // Check results
    let fuzzer = app.world.resource::<ProtocolFuzzer>();
    let results = fuzzer.get_results();
    
    // Verify no crashes occurred
    assert_eq!(results.server_crashes, 0);
    assert_eq!(results.client_crashes, 0);
    
    // Verify all malformed inputs were rejected
    assert_eq!(
        results.malformed_messages_sent,
        results.malformed_messages_rejected,
        "{} malformed messages were incorrectly accepted",
        results.malformed_messages_sent - results.malformed_messages_rejected
    );
}
}

Session and Authentication Testing

Verify players can only control their own actions:

#![allow(unused)]
fn main() {
#[test]
fn test_session_integrity() {
    let mut app = setup_security_test_app(4);
    
    // Setup standard game
    app.world.resource_mut::<TestController>()
        .setup_standard_game_start();
    
    // Attempt to spoof another player's session
    let spoofing_result = app.world.resource_mut::<SecurityTestController>()
        .attempt_session_spoofing(SpoofingAttempt {
            actual_player: 1,
            spoofed_player: 2,
            action: GameAction::PlayLand(2, 201)
        });
    
    // Verify spoofing was detected and prevented
    assert!(!spoofing_result.successful);
    assert_eq!(spoofing_result.detection_reason, DetectionReason::SessionValidationFailed);
    
    // Verify game state remained unchanged
    let game_state = extract_server_game_state(&app);
    assert!(!game_state.turn_history.contains_action_by_player(2, ActionType::PlayLand));
}
}

Continuous Security Testing

Integrated into the CI/CD pipeline:

# .github/workflows/security-tests.yml
name: Security Tests

on:
  push:
    branches: [ main, develop ]
  schedule:
    - cron: '0 0 * * *'  # Run daily

jobs:
  security-tests:
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v2
    
    - name: Setup Rust
      uses: actions-rs/toolchain@v1
      with:
        toolchain: stable
        override: true
    
    - name: Run information hiding tests
      run: cargo test --verbose networking::security::information_hiding
    
    - name: Run protocol validation tests
      run: cargo test --verbose networking::security::protocol_validation
    
    - name: Run anti-cheat tests
      run: cargo test --verbose networking::security::anti_cheat
    
    - name: Run fuzzing tests (longer runtime)
      run: cargo test --verbose networking::security::fuzzing -- --ignored

By implementing these comprehensive security tests, we can ensure our MTG Commander online implementation protects the integrity of the game, prevents cheating, and maintains the proper hidden information characteristics essential to Magic: The Gathering. These tests provide confidence that our networking implementation is not only functional but also secure against potential exploitation attempts.

Security Documentation for MTG Commander Networking

This section covers the security aspects of the MTG Commander game engine's networking implementation. Security is a critical consideration for any multiplayer game, especially one that involves hidden information and competitive gameplay.

Overview

The security implementation for the MTG Commander game engine focuses on several key areas:

  1. Authentication and Authorization: Ensuring only legitimate users can access the game
  2. Hidden Information Management: Protecting game-critical hidden information
  3. Anti-Cheat Measures: Preventing and detecting cheating attempts
  4. Network Security: Securing communication between clients and servers
  5. Data Protection: Safeguarding user data and game state

Security Components

Authentication

The Authentication system ensures that only legitimate users can connect to and participate in games. It covers:

  • User identity verification
  • Session management
  • Credential security
  • Protection against common authentication attacks

Anti-Cheat

The Anti-Cheat system prevents players from gaining unfair advantages through technical means. It addresses:

  • Client modification detection
  • Memory manipulation prevention
  • Network traffic validation
  • Anomaly detection and response
  • Enforcement of game rules

Hidden Information Management

The Hidden Information system protects game-critical information that should be hidden from some or all players. It covers:

  • Player hand protection
  • Library content and order security
  • Face-down card management
  • Selective information revelation
  • Server-side information control

Security Testing

Security testing is a critical aspect of ensuring the robustness of our security measures. For details on how we test security features, see the Security Testing Strategy.

Implementation Principles

Our security implementation follows these core principles:

  1. Defense in Depth: Multiple layers of security to protect against different types of threats
  2. Least Privilege: Components only have access to the information and capabilities they need
  3. Server Authority: The server is the single source of truth for game state
  4. Secure by Default: Security is built into the system from the ground up
  5. Continuous Improvement: Security measures are regularly reviewed and enhanced

Future Enhancements

Planned security enhancements include:

  • Enhanced encryption for sensitive game actions
  • Two-factor authentication support
  • Advanced anti-cheat measures using machine learning
  • Improved security testing automation
  • Expanded security documentation and best practices

This documentation will be updated as security measures evolve.

Anti-Cheat Measures in MTG Commander Game Engine

This document outlines the anti-cheat mechanisms implemented in the MTG Commander game engine to ensure fair gameplay in multiplayer sessions.

Overview

Preventing cheating is essential for maintaining a fair and enjoyable multiplayer experience. The MTG Commander game engine implements several layers of anti-cheat measures to detect and prevent various forms of cheating.

Types of Cheating Addressed

  1. Client Modification: Tampering with the game client to gain advantages
  2. Memory Manipulation: Directly modifying game memory to alter game state
  3. Network Manipulation: Intercepting and modifying network traffic
  4. Information Exposure: Accessing hidden information (opponent's hands, library order)
  5. Automation/Botting: Using automated tools to play the game

Implementation Approach

Server Authority Model

The game uses a server-authoritative model where critical game state and rules enforcement happens on the server:

#![allow(unused)]
fn main() {
// Server-side validation of player actions
fn validate_play_card_action(
    mut commands: Commands,
    mut server: ResMut<RepliconServer>,
    mut play_events: EventReader<PlayCardEvent>,
    game_states: Query<&GameState>,
    players: Query<(Entity, &Player, &Hand)>,
) {
    for event in play_events.read() {
        let client_id = event.client_id;
        let card_id = event.card_id;
        
        // Find the player entity for this client
        if let Some((player_entity, player, hand)) = players
            .iter()
            .find(|(_, player, _)| player.client_id == client_id) 
        {
            // Verify the player actually has this card in hand
            if !hand.cards.contains(&card_id) {
                // Log potential cheating attempt
                warn!("Potential cheat detected: Client {} attempted to play card {} not in hand", client_id, card_id);
                
                // Send cheat detection notification
                server.send_message(client_id, CheatWarning { 
                    reason: "Attempted to play card not in hand".to_string() 
                });
                
                // Skip processing this invalid action
                continue;
            }
            
            // Continue with normal processing if validation passes
            // ...
        }
    }
}
}

Client-Side Integrity Checks

The client includes integrity verification to detect tampering:

#![allow(unused)]
fn main() {
// Client-side integrity check system
fn verify_client_integrity(
    mut integrity_state: ResMut<IntegrityState>,
    mut client: ResMut<RepliconClient>,
) {
    // Perform integrity checks
    let checksum = calculate_code_checksum();
    
    // If checksum doesn't match expected value
    if checksum != integrity_state.expected_checksum {
        // Report integrity failure to server
        client.send_message(IntegrityFailure {
            reason: "Client code integrity check failed".to_string(),
            details: format!("Expected: {}, Got: {}", 
                integrity_state.expected_checksum, checksum),
        });
        
        // Set integrity failure state
        integrity_state.integrity_failed = true;
    }
}
}

Network Traffic Validation

All network traffic is validated for consistency and authenticity:

#![allow(unused)]
fn main() {
// Server-side network traffic validation
fn validate_network_messages(
    mut server: ResMut<RepliconServer>,
    mut message_events: EventReader<NetworkMessageEvent>,
    client_states: Query<&ClientState>,
) {
    for event in message_events.read() {
        // Verify message sequence is correct (no missing messages)
        if let Ok(client_state) = client_states.get(event.client_entity) {
            if event.sequence_number != client_state.next_expected_sequence {
                // Potential replay or injection attack
                warn!("Sequence mismatch for client {}: Expected {}, got {}", 
                    event.client_id, client_state.next_expected_sequence, event.sequence_number);
                
                // Take appropriate action (request resync, disconnect, etc.)
                // ...
            }
        }
        
        // Verify message signature if using signed messages
        if !verify_message_signature(&event.message, &event.signature) {
            // Message tampering detected
            warn!("Invalid message signature from client {}", event.client_id);
            
            // Take appropriate action
            // ...
        }
    }
}
}

Detection and Response

Anomaly Detection

The system monitors for statistical anomalies and suspicious patterns:

  • Unusual timing between actions
  • Statistically improbable sequences of draws or plays
  • Impossible knowledge of hidden information

Response to Detected Cheating

When potential cheating is detected, the system can respond in several ways:

  1. Warning: For minor or first offenses
  2. Game Termination: Ending the current game
  3. Temporary Ban: Restricting access for a period
  4. Permanent Ban: Blocking the user entirely
  5. Silent Monitoring: Continuing to monitor without immediate action

Testing Anti-Cheat Measures

Testing the anti-cheat system involves:

  1. Simulated Attacks: Attempting various cheating methods in a controlled environment
  2. Penetration Testing: Having security experts attempt to bypass protections
  3. False Positive Analysis: Ensuring legitimate players aren't falsely flagged

For detailed testing procedures, see the Security Testing Strategy.

Future Enhancements

Planned improvements to the anti-cheat system include:

  • Machine learning-based anomaly detection
  • Enhanced client-side protection against memory editing
  • Improved server-side validation of complex game states
  • Community reporting and review system

This documentation will be updated as anti-cheat measures evolve.

Data Validation

Encryption

Authentication in MTG Commander Game Engine

This document outlines the authentication mechanisms used in the MTG Commander game engine's multiplayer implementation.

Overview

Authentication is a critical component of the networking system, ensuring that only authorized users can connect to and participate in games. The system uses bevy_replicon along with custom authentication logic to establish secure connections.

Authentication Flow

  1. User Login: Players begin by providing credentials or connecting via a trusted third-party service
  2. Credential Validation: Server validates credentials against stored records or third-party responses
  3. Session Token Generation: Upon successful validation, a unique session token is generated
  4. Client Authentication: The client uses this token for all subsequent communications
  5. Session Management: Server tracks active sessions and handles expiration/revocation

Implementation Details

Server-Side Authentication

#![allow(unused)]
fn main() {
// Authentication handler on server
fn handle_authentication(
    mut commands: Commands,
    mut server: ResMut<ReplicionServer>,
    mut auth_events: EventReader<AuthenticationEvent>,
) {
    for event in auth_events.read() {
        // Validate credentials
        if validate_credentials(&event.credentials) {
            // Generate session token
            let session_token = generate_session_token();
            
            // Store session information
            commands.spawn((
                SessionData {
                    user_id: event.user_id,
                    token: session_token.clone(),
                    expiration: Utc::now() + Duration::hours(24),
                },
                Replicated,
            ));
            
            // Send authentication response
            server.send_message(event.client_id, AuthSuccess { token: session_token });
        } else {
            // Failed authentication
            server.send_message(event.client_id, AuthFailure { reason: "Invalid credentials".to_string() });
        }
    }
}
}

Client-Side Authentication

#![allow(unused)]
fn main() {
// Client authentication system
fn authenticate_client(
    mut client: ResMut<RepliconClient>,
    mut auth_state: ResMut<AuthenticationState>,
    keyboard: Res<Input<KeyCode>>,
    mut ui_state: ResMut<UiState>,
) {
    // Handle authentication flow based on state
    match *auth_state {
        AuthenticationState::NotAuthenticated => {
            if ui_state.credentials_ready {
                // Send credentials to server
                client.send_message(AuthRequest {
                    username: ui_state.username.clone(),
                    password: ui_state.password.clone(),
                });
                *auth_state = AuthenticationState::Authenticating;
            }
        },
        AuthenticationState::Authenticating => {
            // Wait for server response (handled in another system)
        },
        AuthenticationState::Authenticated => {
            // Authentication complete, proceed to lobby or game
        }
    }
}
}

Security Considerations

Token Security

  • Session tokens are cryptographically secure random values
  • Tokens are transmitted securely and stored with appropriate protections
  • Token lifetimes are limited and can be revoked if suspicious activity is detected

Password Security

  • Passwords are never stored in plaintext
  • Argon2id hashing with appropriate parameters is used for password storage
  • Password policy enforces minimum complexity requirements

Protection Against Common Attacks

  • Rate limiting is implemented to prevent brute force attempts
  • Protection against replay attacks using token rotation and nonces
  • Secure communication channel to prevent man-in-the-middle attacks

Future Enhancements

  • OAuth integration for third-party authentication
  • Two-factor authentication for additional security
  • Hardware token support for high-security environments
  • Biometric authentication for supported platforms

Testing the Authentication System

Comprehensive testing is critical for authentication systems. See the Security Testing Strategy for details on the testing approach for authentication.


This documentation will be updated as the authentication system evolves.

Hidden Information Management in MTG Commander

This document outlines the approach to managing hidden information in the MTG Commander game engine's multiplayer implementation.

Overview

Magic: The Gathering relies heavily on hidden information as a core gameplay mechanic. Cards in players' hands, the order of libraries, and face-down cards are all examples of information that must be hidden from some or all players. Properly securing this information is critical to maintaining game integrity.

Types of Hidden Information

  1. Player Hands: Cards in a player's hand should be visible only to that player
  2. Libraries: The order and content of libraries should be hidden from all players
  3. Face-down Cards: Cards played face-down should have their identity hidden
  4. Revealed Cards: Cards revealed to specific players should only be visible to those players
  5. Search Results: When a player searches their library, only they should see the results

Implementation Approach

Server-Side Information Management

The server maintains the authoritative game state and controls what information is sent to each client:

#![allow(unused)]
fn main() {
// System to update visible information for each client
fn update_client_visible_information(
    mut server: ResMut<RepliconServer>,
    game_state: Res<GameState>,
    players: Query<(Entity, &Player, &Hand, &Library)>,
    connected_clients: Res<ConnectedClients>,
) {
    // For each connected client
    for client_id in connected_clients.clients.keys() {
        // Find the player entity for this client
        let player_entity = players
            .iter()
            .find(|(_, player, _, _)| player.client_id == *client_id)
            .map(|(entity, _, _, _)| entity);
        
        // Prepare visible game state for this client
        let mut visible_state = VisibleGameState {
            // Common visible information
            battlefield: game_state.battlefield.clone(),
            graveyards: game_state.graveyards.clone(),
            exiled_cards: game_state.exiled_cards.clone(),
            
            // Player-specific information
            player_hands: HashMap::new(),
            library_counts: HashMap::new(),
            library_tops: HashMap::new(),
        };
        
        // Add information about each player's hand and library
        for (entity, player, hand, library) in players.iter() {
            // If this is the current player, show their full hand
            if Some(entity) == player_entity {
                visible_state.player_hands.insert(player.id, hand.cards.clone());
            } else {
                // For other players, only show card count
                visible_state.player_hands.insert(player.id, vec![CardBack; hand.cards.len()]);
            }
            
            // For all players, only show library count, not contents
            visible_state.library_counts.insert(player.id, library.cards.len());
            
            // Show top card if it's been revealed
            if library.top_revealed {
                visible_state.library_tops.insert(player.id, library.cards.first().cloned());
            }
        }
        
        // Send the visible state to this client
        server.send_message(*client_id, ClientGameState { state: visible_state });
    }
}
}

Client-Side Information Handling

The client displays only the information it receives from the server:

#![allow(unused)]
fn main() {
// Client system to update UI based on visible information
fn update_game_ui(
    visible_state: Res<VisibleGameState>,
    mut ui_state: ResMut<UiState>,
    local_player_id: Res<LocalPlayerId>,
) {
    // Update battlefield display
    ui_state.battlefield_cards = visible_state.battlefield.clone();
    
    // Update hand display (only shows full information for local player)
    if let Some(hand) = visible_state.player_hands.get(&local_player_id.0) {
        ui_state.hand_cards = hand.clone();
    }
    
    // Update opponent hand displays (only shows card backs)
    for (player_id, hand) in visible_state.player_hands.iter() {
        if *player_id != local_player_id.0 {
            ui_state.opponent_hand_counts.insert(*player_id, hand.len());
        }
    }
    
    // Update library displays (only shows counts)
    for (player_id, count) in visible_state.library_counts.iter() {
        ui_state.library_counts.insert(*player_id, *count);
    }
    
    // Update revealed top cards if any
    for (player_id, card) in visible_state.library_tops.iter() {
        if let Some(card) = card {
            ui_state.revealed_tops.insert(*player_id, card.clone());
        }
    }
}
}

Security Measures

Encryption

All network traffic containing hidden information is encrypted:

#![allow(unused)]
fn main() {
// Example of encrypting sensitive game data
fn encrypt_game_message(
    message: &GameMessage,
    encryption_key: &EncryptionKey,
) -> EncryptedMessage {
    // Serialize the message
    let serialized = bincode::serialize(message).expect("Failed to serialize message");
    
    // Encrypt the serialized data
    let nonce = generate_nonce();
    let cipher = ChaCha20Poly1305::new(encryption_key.as_ref().into());
    let ciphertext = cipher
        .encrypt(&nonce, serialized.as_ref())
        .expect("Encryption failed");
    
    EncryptedMessage {
        ciphertext,
        nonce,
    }
}
}

Server Authority

The server is the single source of truth for all game state:

  • Clients never directly modify hidden information
  • All actions that would reveal information are processed by the server
  • The server controls exactly what information each client can see

Validation

The server validates all client actions to ensure they don't attempt to access hidden information:

#![allow(unused)]
fn main() {
// Validate a player's attempt to look at cards
fn validate_look_at_cards_action(
    mut commands: Commands,
    mut server: ResMut<RepliconServer>,
    mut look_events: EventReader<LookAtCardsEvent>,
    game_state: Res<GameState>,
    players: Query<(Entity, &Player)>,
) {
    for event in look_events.read() {
        let client_id = event.client_id;
        let target_zone = event.zone;
        let target_player_id = event.player_id;
        
        // Check if this action is allowed by a game effect
        let is_allowed = game_state
            .active_effects
            .iter()
            .any(|effect| effect.allows_looking_at(target_zone, target_player_id, client_id));
        
        if !is_allowed {
            // Log potential information leak attempt
            warn!("Potential information leak attempt: Client {} tried to look at {:?} of player {} without permission", 
                client_id, target_zone, target_player_id);
            
            // Reject the action
            server.send_message(client_id, ActionRejected { 
                reason: "Not allowed to look at those cards".to_string() 
            });
            
            continue;
        }
        
        // Process the legitimate look action
        // ...
    }
}
}

Testing Hidden Information Security

Testing the hidden information system involves:

  1. Penetration Testing: Attempting to access hidden information through various attack vectors
  2. Protocol Analysis: Examining network traffic to ensure hidden information isn't leaked
  3. Edge Case Testing: Testing unusual game states that might reveal hidden information

For detailed testing procedures, see the Security Testing Strategy.

Future Enhancements

Planned improvements to hidden information management include:

  • Enhanced encryption for particularly sensitive game actions
  • Improved obfuscation of client-side game state
  • Additional validation for complex card interactions involving hidden information
  • Support for spectator mode with appropriate information hiding

This documentation will be updated as hidden information management evolves.

UI Integration

Game Engine

This section covers the core game engine that powers Rummage, the Magic: The Gathering Commander format implementation built with Bevy 0.15.x.

Architecture Overview

The Rummage game engine is built on Bevy's Entity Component System (ECS) architecture, providing a robust foundation for implementing the complex rules and interactions of Magic: The Gathering. The engine is designed with the following principles:

  • Separation of Concerns: Game logic is separated from rendering and input handling
  • Data-Oriented Design: Game state is represented as components on entities
  • Event-Driven Architecture: Systems communicate through events
  • Deterministic Execution: Game logic runs deterministically for network play
  • Extensible Systems: New cards and mechanics can be added without modifying core systems

Core Components

The game engine consists of several interconnected systems:

  • State Management: How game state is tracked and updated
    • Entity and component management
    • Game state progression
    • Player state handling
  • Event System: How game events are processed and handled
    • Event dispatching
    • Event handling
    • Custom event types
  • Snapshot System: How game state is serialized for networking and replays
    • State serialization
    • Deterministic snapshots
    • Replay functionality

Integration Points

The game engine integrates with several other systems:

Plugin Structure

The game engine is organized as a set of Bevy plugins that can be added to your Bevy application:

#![allow(unused)]
fn main() {
// Initialize the game engine
App::new()
    .add_plugins(DefaultPlugins)
    .add_plugins(RummageGameEnginePlugin)
    .run();
}

Implementation Status

The game engine currently implements:

  • ✅ Core turn structure
  • ✅ Basic card mechanics
  • ✅ Zone management
  • 🔄 Stack implementation
  • 🔄 Combat system
  • ⚠️ Comprehensive rules coverage
  • ⚠️ Advanced card interactions

Extending the Engine

For information on extending the game engine with new cards or mechanics, see the Development Guide.

State Management

The state management system is responsible for tracking, updating, and maintaining the consistency of game state in Rummage. It represents all the information needed to describe the current state of a Magic: The Gathering game.

State Components

Game state is divided into several major components:

Player State

Players have various attributes tracked by the state system:

  • Life totals: Current life points
  • Mana pool: Available mana for casting spells
  • Hand: Cards in the player's hand
  • Library: Cards in the player's deck
  • Graveyard: Cards in the player's discard pile
  • Commander zone: Special zone for Commander cards

Card State

Cards have multiple state properties:

  • Zone location: Current game zone (hand, battlefield, etc.)
  • Physical state: Tapped/untapped, face-up/face-down, etc.
  • Counters: Various counters on the card
  • Attached entities: Equipment, Auras, etc.
  • Temporary effects: Effects currently modifying the card

Game Flow State

The overall game flow has state:

  • Current turn: Which player's turn it is
  • Phase/step: Current phase and step in the turn
  • Priority holder: Which player currently has priority
  • Stack: Spells and abilities waiting to resolve

ECS Implementation

State is implemented using Bevy's Entity Component System:

  • Entities: Cards, players, and other game objects
  • Components: State attributes attached to entities
  • Systems: Logic that operates on components
  • Resources: Global state shared across entities

Example Implementation

Here's a simplified example of how player state might be implemented:

#![allow(unused)]
fn main() {
// Player life component
#[derive(Component)]
pub struct Life {
    pub current: u32,
    pub maximum: u32,
}

// Player mana pool component
#[derive(Component)]
pub struct ManaPool {
    pub white: u32,
    pub blue: u32,
    pub black: u32,
    pub red: u32,
    pub green: u32,
    pub colorless: u32,
}

// System to update life totals
fn update_life_system(
    mut player_query: Query<&mut Life>,
    mut life_events: EventReader<LifeChangeEvent>,
) {
    for event in life_events.read() {
        if let Ok(mut life) = player_query.get_mut(event.player_entity) {
            life.current = (life.current as i32 + event.amount).max(0) as u32;
        }
    }
}
}

State Change Process

State changes follow a specific process:

  1. Event-driven: Changes are triggered by events
  2. Validation: Changes are validated against game rules
  3. Execution: Changes are applied to components
  4. Side effects: Changes may trigger additional events
  5. State-based actions: After each change, state-based actions are checked

Integration with Rules

The state system is tightly integrated with the MTG rules implementation:

  • Rules define valid state transitions
  • State-based actions maintain invariants
  • Turn structure progresses through defined states

Persistence

The state system supports:

  • Serialization: Convert state to data format for storage
  • Deserialization: Restore state from data
  • Snapshots: Capture state at a point in time
  • Rollback: Restore to a previous state if needed

Networking

For multiplayer games, state is synchronized across clients. See Networking for details on how state is kept consistent across the network.

The state system works closely with:

Subgames and Game Restarting Implementation

This document outlines the technical implementation of subgames (like those created by Shahrazad) and game restarting mechanics (like Karn Liberated's ultimate ability) within our engine.

Component Design

The implementation uses several key components and resources to manage subgames and game restarting:

#![allow(unused)]
fn main() {
// Tracks whether we're currently in a subgame
#[derive(Resource)]
pub struct SubgameState {
    /// Stack of game states, with the main game at the bottom
    pub game_stack: Vec<GameSnapshot>,
    /// Current subgame depth (0 = main game)
    pub depth: usize,
}

// Marks cards that were exiled by Karn Liberated
#[derive(Component)]
pub struct ExiledWithKarn;

// Component that can restart the game
#[derive(Component)]
pub struct GameRestarter {
    pub condition: GameRestarterCondition,
}

// Enum defining what triggers game restart
pub enum GameRestarterCondition {
    KarnUltimate,
    Custom(Box<dyn Fn(&World) -> bool + Send + Sync + 'static>),
}
}

Subgame Implementation

Subgames are implemented using a stack-based approach that leverages our snapshot system:

Starting a Subgame

When a card like Shahrazad resolves:

  1. The current game state is captured via the snapshot system
  2. The snapshot is pushed onto the game_stack in SubgameState
  3. The depth counter is incremented
  4. A new game state is initialized with the appropriate starting conditions
  5. Players' libraries from the main game become their decks in the subgame
#![allow(unused)]
fn main() {
fn start_subgame(
    mut commands: Commands,
    mut subgame_state: ResMut<SubgameState>,
    snapshot_system: Res<SnapshotSystem>,
    // Other dependencies...
) {
    // Take snapshot of current game
    let current_game = snapshot_system.capture_game_state();
    
    // Push to stack and increment depth
    subgame_state.game_stack.push(current_game);
    subgame_state.depth += 1;
    
    // Initialize new game state for subgame...
    // Transfer libraries from parent game to new subgame...
}
}

Ending a Subgame

When a subgame concludes:

  1. The result of the subgame is determined (winner/loser)
  2. The most recent snapshot is popped from the game_stack
  3. The depth counter is decremented
  4. The main game state is restored from the snapshot
  5. Any effects from the subgame are applied to the main game (loser loses half life)
#![allow(unused)]
fn main() {
fn end_subgame(
    mut commands: Commands,
    mut subgame_state: ResMut<SubgameState>,
    snapshot_system: Res<SnapshotSystem>,
    game_result: Res<SubgameResult>,
    // Other dependencies...
) {
    // Pop game state and decrement depth
    let parent_game = subgame_state.game_stack.pop().unwrap();
    subgame_state.depth -= 1;
    
    // Restore parent game state
    snapshot_system.restore_game_state(parent_game);
    
    // Apply subgame results to parent game
    // (loser loses half their life, rounded up)...
}
}

Game Restarting Implementation

Game restarting is a complete reinitialization of the game state with some information carried over:

Tracking Exiled Cards

Cards exiled with abilities like Karn Liberated's are marked with special components:

#![allow(unused)]
fn main() {
fn track_karn_exile(
    mut commands: Commands,
    karn_query: Query<Entity, With<KarnLiberated>>,
    exile_events: EventReader<CardExiledEvent>,
) {
    for event in exile_events.read() {
        if event.source_ability == AbilityType::KarnExile {
            commands.entity(event.card_entity).insert(ExiledWithKarn);
        }
    }
}
}

Restarting the Game

When a game restart ability triggers:

  1. Cards exiled with Karn are identified and stored in a temporary resource
  2. All existing game resources are cleaned up
  3. A new game is initialized with standard starting conditions
  4. The exiled cards are placed in their owners' hands in the new game
#![allow(unused)]
fn main() {
fn restart_game(
    mut commands: Commands,
    restart_events: EventReader<GameRestartEvent>,
    exiled_cards: Query<(Entity, &Owner), With<ExiledWithKarn>>,
    // Other dependencies...
) {
    if restart_events.is_empty() {
        return;
    }
    
    // Store information about exiled cards
    let cards_to_return = exiled_cards
        .iter()
        .map(|(entity, owner)| (entity, owner.0))
        .collect::<Vec<_>>();
    
    // Clean up existing game state...
    
    // Initialize new game...
    
    // Return exiled cards to their owners' hands
    for (card_entity, owner_id) in cards_to_return {
        // Move card to owner's hand in the new game
        // ...
    }
}
}

Interacting with Game Systems

Both subgames and game restarting interact with several core systems:

Snapshot System Integration

The Snapshot System is crucial for both features:

  • Subgames use it to preserve and restore game states
  • Game restarting uses it to track information that needs to persist through restarts

Turn System Integration

The turn system needs special handling for subgames:

  • Subgames have their own turn structure independent of the main game
  • When returning from a subgame, the turn structure of the main game must be properly restored

Zone Management

Both features require special zone handling:

  • Subgames need to track cards that leave the subgame
  • Game restarting needs to move cards to their proper starting zones

Error Handling and Edge Cases

The implementation includes handling for various edge cases:

  • Nested subgames (subgames within subgames)
  • Game restarts during a subgame
  • Subgame creation during a game restart
  • Corrupted state recovery
  • Performance considerations for deep subgame nesting

See the MTG Rules documentation for more information on the rules governing these mechanics.

Event System

The Rummage event system is a core component of the game engine that enables communication between different systems and components in a decoupled manner. Events are used to notify about changes in game state, trigger effects, and coordinate actions between different parts of the codebase.

Event Architecture

The event system is built on Bevy's native event system, which provides:

  • Type-safe events: Each event is a strongly-typed struct
  • Single-frame lifetime: Events are processed within a single frame by default
  • Multiple readers: Multiple systems can respond to the same events
  • Ordered processing: Events are processed in a deterministic order

Core Event Types

Rummage implements several categories of events:

Game Flow Events

Events that control the progression of the game:

  • TurnStartEvent: Signals the start of a new turn
  • PhaseChangeEvent: Indicates a change in the current phase
  • StepChangeEvent: Indicates a change in the current step
  • PriorityPassedEvent: Signals when priority is passed between players

Card Events

Events related to card actions:

  • CardPlayedEvent: Triggered when a card is played from hand
  • CardMovedEvent: Signals when a card changes zones
  • CardStateChangedEvent: Indicates a change in a card's state (tapped, etc.)
  • CardTargetedEvent: Triggered when a card is targeted by a spell or ability

Player Events

Events related to player actions and state:

  • PlayerDamageEvent: Signals when a player takes damage
  • PlayerGainLifeEvent: Triggered when a player gains life
  • PlayerDrawCardEvent: Indicates a card draw
  • PlayerLosesEvent: Signals when a player loses the game

Custom Events

Game mechanics and card effects often require custom events. These can be created by defining a new event struct:

#![allow(unused)]
fn main() {
#[derive(Event)]
pub struct ExileCardEvent {
    pub card_entity: Entity,
    pub source_entity: Option<Entity>,
    pub until_end_of_turn: bool,
}
}

Event Processing

Events are processed using Bevy's event readers:

#![allow(unused)]
fn main() {
fn process_card_played_events(
    mut event_reader: EventReader<CardPlayedEvent>,
    mut commands: Commands,
    // other system parameters
) {
    for event in event_reader.read() {
        // React to the card being played
        // Trigger effects, update state, etc.
    }
}
}

Event Broadcasting

Systems can broadcast events using Bevy's event writers:

#![allow(unused)]
fn main() {
fn play_card_system(
    mut commands: Commands,
    mut event_writer: EventWriter<CardPlayedEvent>,
    // other system parameters
) {
    // Logic to determine a card is played
    
    // Broadcast the event
    event_writer.send(CardPlayedEvent {
        card_entity,
        player_entity,
        from_zone: Zone::Hand,
    });
}
}

Event-Driven Architecture

The event system enables an event-driven architecture where:

  1. Actions in the game broadcast events
  2. Systems listen for relevant events
  3. Events trigger state changes and additional events
  4. Complex interactions emerge from simple event chains

This approach simplifies the implementation of MTG's complex rules and card interactions.

Integration

The event system integrates with:

For more information on implementing card effects using events, see Card Effects.

Snapshot System

The snapshot system is a core component of the Rummage game engine that provides serialization and deserialization of game state. This system is essential for networking, replay functionality, and save/load capabilities.

Overview

The snapshot system enables:

  • Networked Multiplayer: Synchronizing game state between players
  • Replay System: Recording and replaying games
  • Undo Functionality: Allowing players to revert to previous game states
  • Save/Load: Persisting game state between sessions
  • Crash Recovery: Automatically recovering from application crashes

Documentation Sections

This documentation covers the following aspects of the snapshot system:

Technical Architecture

The snapshot system uses a component-based approach to serialize and deserialize entities in the game world. It captures the state of all relevant entities and components at a specific point in time, allowing the game state to be reconstructed later.

Key Features

  • Selective Serialization: Only relevant components are included in snapshots
  • Efficient Storage: Compact binary representation of game state
  • Event-Based Triggering: Snapshots can be triggered by game events
  • Queue Management: Processing of snapshots is managed to avoid performance impact
  • Integration Points: Well-defined interfaces for networking and replay systems
  • Persistent Storage: Robust save/load functionality with automatic recovery

Usage in Rummage

The snapshot system is used throughout Rummage:

  1. Networking: Synchronizing game state between clients
  2. Game History: Recording turn-by-turn snapshots for replay and analysis
  3. Testing: Verifying game state correctness in unit and integration tests
  4. Save/Load: Allowing games to be saved and resumed later
  5. Crash Recovery: Automatically restoring state after unexpected crashes
  6. Rollback: Supporting undo functionality for players

bevy_persistent Integration

The snapshot system leverages the bevy_persistent crate to provide robust save/load functionality:

  • Automatic Persistence: Game state is automatically saved at key moments
  • Error Recovery: Comprehensive error handling for corrupted saves
  • Efficient Format: Optimized binary serialization for large game states
  • Incremental Saves: Only changed data is saved to improve performance
  • Cross-Platform: Works consistently across all supported platforms

See the detailed documentation sections for more information on each aspect of the snapshot system.

Snapshot System Overview

Introduction

A snapshot in Rummage is a serializable representation of the game state at a specific point in time. It captures all the information needed to reproduce the exact state of the game, including cards, player data, and game progress.

Core Concepts

What is a Snapshot?

A snapshot captures:

  • Entities: Game objects like cards, players, and zones
  • Components: Properties and state associated with entities
  • Resources: Global game state and configuration
  • Relationships: Connections between entities (e.g., a card in a zone)

When Are Snapshots Created?

Snapshots can be created:

  • On Turn Change: Capture state at the beginning of each turn
  • On Phase Change: Record state at critical phase transitions
  • On Demand: Manually triggered for testing or replay purposes
  • Before Network Updates: Prior to sending state updates to clients
  • At Save Points: When a user wants to save game progress

Snapshot Lifecycle

The typical lifecycle of a snapshot is:

  1. Creation: Game state is captured and serialized
  2. Storage: The snapshot is stored in memory or on disk
  3. Processing: Various systems may analyze or transform the snapshot
  4. Application: The snapshot is used to restore game state (for replay, rollback, etc.)
  5. Disposal: Old snapshots are removed when no longer needed

Technical Details

Core Types

The main types in the snapshot system are:

#![allow(unused)]
fn main() {
/// A serializable snapshot of the game state
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct GameSnapshot {
    /// Unique identifier for the snapshot
    pub id: Uuid,
    /// The game turn the snapshot was taken on
    pub turn: u32,
    /// The phase within the turn
    pub phase: Phase,
    /// Active player when snapshot was taken
    pub active_player: usize,
    /// Serialized game data
    pub game_data: HashMap<String, Vec<u8>>,
    /// Timestamp when the snapshot was created
    pub timestamp: f64,
}

/// Tracks pending snapshot operations
#[derive(Resource, Default)]
pub struct PendingSnapshots {
    /// Snapshots waiting to be processed
    pub queue: VecDeque<GameSnapshot>,
    /// Whether snapshot processing is paused
    pub paused: bool,
}

/// Marker for entities that should be included in snapshots
#[derive(Component)]
pub struct Snapshotable;
}

Serialization Strategy

The snapshot system uses a selective serialization strategy:

  1. Marker Components: Only entities with Snapshotable components are included
  2. Component Filtering: Only necessary components are serialized
  3. Binary Encoding: Data is encoded efficiently to minimize size
  4. ID Mapping: Entity IDs are mapped to ensure consistency across sessions

Use Cases

Networked Multiplayer

In multiplayer games, snapshots are used to:

  • Synchronize game state between clients
  • Verify state consistency across the network
  • Handle player disconnections and reconnections
  • Provide authoritative state for conflict resolution

Replay System

For game replays, snapshots enable:

  • Recording complete game history
  • Navigating back and forth through game turns
  • Analyzing gameplay decisions
  • Sharing interesting game states

Save/Load Functionality

Snapshots make save/load possible by:

  • Capturing all necessary data to resume play
  • Creating portable save files
  • Supporting different save points within a game
  • Ensuring version compatibility

Testing

For testing purposes, snapshots allow:

  • Creating reproducible test scenarios
  • Verifying state transitions
  • Validating rule implementations
  • Comparing expected vs. actual outcomes

Next Steps

For more detailed information, continue to:

Snapshot System Implementation

This document covers the technical implementation details of the Rummage snapshot system.

Plugin Structure

The snapshot system is implemented as a Bevy plugin that can be added to the application:

#![allow(unused)]
fn main() {
pub struct SnapshotPlugin;

impl Plugin for SnapshotPlugin {
    fn build(&self, app: &mut App) {
        app
            // Register resources
            .init_resource::<PendingSnapshots>()
            .init_resource::<SnapshotConfig>()
            
            // Register snapshot events
            .add_event::<SnapshotEvent>()
            .add_event::<SnapshotProcessedEvent>()
            
            // Add snapshot systems to the appropriate schedule
            .add_systems(Update, (
                handle_snapshot_events,
                process_pending_snapshots,
                trigger_snapshot_on_turn_change,
                trigger_snapshot_on_phase_change,
            ).chain());
    }
}
}

Core Components

Configuration

The snapshot system is configured through a resource:

#![allow(unused)]
fn main() {
#[derive(Resource)]
pub struct SnapshotConfig {
    /// Whether to automatically create snapshots on turn changes
    pub auto_snapshot_on_turn: bool,
    /// Whether to automatically create snapshots on phase changes
    pub auto_snapshot_on_phase: bool,
    /// Maximum number of snapshots to process per frame
    pub max_snapshots_per_frame: usize,
    /// Maximum number of snapshots to keep in history
    pub max_history_size: usize,
    /// Whether to compress snapshots
    pub use_compression: bool,
}

impl Default for SnapshotConfig {
    fn default() -> Self {
        Self {
            auto_snapshot_on_turn: true,
            auto_snapshot_on_phase: false,
            max_snapshots_per_frame: 1,
            max_history_size: 100,
            use_compression: true,
        }
    }
}
}

Event Types

The system communicates through events:

#![allow(unused)]
fn main() {
/// Events for snapshot operations
#[derive(Event)]
pub enum SnapshotEvent {
    /// Take a new snapshot of the current state
    Take,
    /// Apply a specific snapshot
    Apply(Uuid),
    /// Save the current snapshot to disk
    Save(String),
    /// Load a snapshot from disk
    Load(String),
}

/// Event fired when a snapshot has been processed
#[derive(Event)]
pub struct SnapshotProcessedEvent {
    /// The ID of the processed snapshot
    pub id: Uuid,
    /// Whether processing succeeded
    pub success: bool,
}
}

Creating Snapshots

The core snapshot creation logic:

#![allow(unused)]
fn main() {
fn create_game_snapshot(
    world: &World,
    game_state: &GameState,
) -> GameSnapshot {
    // Create a new empty snapshot
    let mut snapshot = GameSnapshot {
        id: Uuid::new_v4(),
        turn: game_state.turn,
        phase: game_state.phase.clone(),
        active_player: game_state.active_player,
        game_data: HashMap::new(),
        timestamp: world.resource::<Time>().elapsed_seconds(),
    };
    
    // Find all snapshotable entities
    let mut snapshotable_query = world.query_filtered::<Entity, With<Snapshotable>>();
    
    // For each snapshotable entity, serialize its components
    for entity in snapshotable_query.iter(world) {
        serialize_entity_to_snapshot(world, entity, &mut snapshot);
    }
    
    snapshot
}

fn serialize_entity_to_snapshot(
    world: &World,
    entity: Entity,
    snapshot: &mut GameSnapshot,
) {
    // Get all component types registered for this entity
    let entity_components = world.entity(entity).archetype().components();
    
    // Create a buffer to store the entity's serialized components
    let mut entity_data = Vec::new();
    
    // Write the entity ID
    let entity_id = entity.to_bits();
    entity_data.extend_from_slice(&entity_id.to_le_bytes());
    
    // For each component type
    for component_id in entity_components.iter() {
        // Skip certain component types that don't need to be serialized
        if should_skip_component(component_id) {
            continue;
        }
        
        // Get the component storage
        if let Some(component_info) = world.components().get_info(component_id) {
            // Get the component data for this entity
            if let Some(component_data) = component_info.get_component(world, entity) {
                // Write the component ID
                entity_data.extend_from_slice(&component_id.to_le_bytes());
                
                // Write the component size
                let size = component_data.len();
                entity_data.extend_from_slice(&size.to_le_bytes());
                
                // Write the component data
                entity_data.extend_from_slice(component_data);
            }
        }
    }
    
    // Add the entity's data to the snapshot
    snapshot.game_data.insert(entity.index().to_string(), entity_data);
}
}

Automatic Snapshot Triggering

Snapshots can be automatically triggered by game events:

#![allow(unused)]
fn main() {
fn trigger_snapshot_on_turn_change(
    mut turn_events: EventReader<TurnChangeEvent>,
    mut snapshot_events: EventWriter<SnapshotEvent>,
    config: Res<SnapshotConfig>,
) {
    if !config.auto_snapshot_on_turn {
        return;
    }
    
    for _ in turn_events.iter() {
        // Create a new snapshot event
        snapshot_events.send(SnapshotEvent::Take);
    }
}

fn trigger_snapshot_on_phase_change(
    mut phase_events: EventReader<PhaseChangeEvent>,
    mut snapshot_events: EventWriter<SnapshotEvent>,
    config: Res<SnapshotConfig>,
) {
    if !config.auto_snapshot_on_phase {
        return;
    }
    
    for _ in phase_events.iter() {
        // Create a new snapshot event
        snapshot_events.send(SnapshotEvent::Take);
    }
}
}

Processing Snapshots

The process_pending_snapshots system handles snapshots in the pending queue:

#![allow(unused)]
fn main() {
/// Process any pending snapshots in the queue
pub fn process_pending_snapshots(
    mut commands: Commands,
    mut pending: ResMut<PendingSnapshots>,
    mut processed_events: EventWriter<SnapshotProcessedEvent>,
    config: Res<SnapshotConfig>,
) {
    // Skip if processing is paused
    if pending.paused {
        return;
    }
    
    // Process up to config.max_snapshots_per_frame
    let to_process = pending.queue.len().min(config.max_snapshots_per_frame);
    
    for _ in 0..to_process {
        if let Some(snapshot) = pending.queue.pop_front() {
            // Apply the snapshot to the game state
            apply_snapshot(&mut commands, &snapshot);
            
            // Notify that a snapshot was processed
            processed_events.send(SnapshotProcessedEvent {
                id: snapshot.id,
                success: true,
            });
        }
    }
}
}

Applying Snapshots

To restore a game state from a snapshot:

#![allow(unused)]
fn main() {
fn apply_snapshot(
    commands: &mut Commands,
    snapshot: &GameSnapshot,
) {
    // Clear existing entities that should be replaced by the snapshot
    clear_snapshotable_entities(commands);
    
    // Restore global game state
    restore_game_state(commands, snapshot);
    
    // For each entity in the snapshot
    for (entity_key, entity_data) in &snapshot.game_data {
        // Create a new entity
        let entity = commands.spawn_empty().id();
        
        // Deserialize and add components
        deserialize_entity_components(commands, entity, entity_data);
    }
}

fn deserialize_entity_components(
    commands: &mut Commands,
    entity: Entity,
    entity_data: &[u8],
) {
    let mut offset = 8; // Skip the entity ID
    
    // While there's more data to read
    while offset < entity_data.len() {
        // Read the component ID
        let component_id_bytes = &entity_data[offset..offset+8];
        let component_id = u64::from_le_bytes(component_id_bytes.try_into().unwrap());
        offset += 8;
        
        // Read the component size
        let size_bytes = &entity_data[offset..offset+8];
        let size = usize::from_le_bytes(size_bytes.try_into().unwrap());
        offset += 8;
        
        // Read the component data
        let component_data = &entity_data[offset..offset+size];
        offset += size;
        
        // Deserialize and add the component
        add_component_from_bytes(commands, entity, component_id, component_data);
    }
}
}

Snapshot Storage

The system supports saving snapshots to disk and loading them later:

#![allow(unused)]
fn main() {
fn save_snapshot_to_disk(
    snapshot: &GameSnapshot,
    path: &str,
) -> Result<(), std::io::Error> {
    // Serialize the snapshot
    let serialized = bincode::serialize(snapshot)
        .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?;
    
    // Compress if configured
    let final_data = if snapshot.use_compression {
        // Apply compression
        let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
        encoder.write_all(&serialized)?;
        encoder.finish()?
    } else {
        serialized
    };
    
    // Write to file
    std::fs::write(path, final_data)?;
    
    Ok(())
}

fn load_snapshot_from_disk(
    path: &str,
) -> Result<GameSnapshot, std::io::Error> {
    // Read file
    let data = std::fs::read(path)?;
    
    // Check for compression
    let serialized = if is_compressed(&data) {
        // Decompress
        let mut decoder = flate2::read::GzDecoder::new(&data[..]);
        let mut decompressed = Vec::new();
        decoder.read_to_end(&mut decompressed)?;
        decompressed
    } else {
        data
    };
    
    // Deserialize
    let snapshot = bincode::deserialize(&serialized)
        .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?;
    
    Ok(snapshot)
}
}

Performance Considerations

The snapshot system is designed with performance in mind:

  1. Selective Serialization: Only necessary components are included
  2. Batched Processing: Limits how many snapshots are processed per frame
  3. Compression Options: Configurable to balance size vs. speed
  4. Marker Components: Only entities explicitly marked are included
  5. Queue Management: Background processing to minimize frame time impact

Integration Points

The snapshot system integrates with other systems through:

  1. Events: For triggering and receiving snapshot operations
  2. Component Markers: To specify what entities should be included
  3. Configuration: To control behavior based on game requirements
  4. Plugins: To integrate with other game systems

These integration points provide flexibility while maintaining clean separation of concerns.

Next Steps

Snapshot Integration with Networking

This document explains how the snapshot system integrates with Rummage's networking capabilities to enable multiplayer gameplay.

Overview

The snapshot system forms a critical part of Rummage's networking architecture, providing:

  1. State Synchronization: Ensuring all clients have the same game state
  2. Rollback Capability: Allowing recovery from network disruptions
  3. Deterministic Execution: Working with deterministic systems for consistent gameplay
  4. Hidden Information Management: Handling information that should be hidden from certain players

Integration Architecture

Core Components

The networking integration uses these key components:

#![allow(unused)]
fn main() {
/// Plugin that integrates the snapshot system with networking
pub struct NetworkSnapshotPlugin;

/// Resource that tracks network-specific snapshot configuration
#[derive(Resource)]
pub struct NetworkSnapshotConfig {
    /// Frequency of network snapshot updates (in seconds)
    pub sync_frequency: f32,
    /// Maximum size of a snapshot packet (in bytes)
    pub max_packet_size: usize,
    /// Whether to compress network snapshots
    pub compress_network_snapshots: bool,
    /// Number of snapshots to keep for rollback purposes
    pub rollback_history_size: usize,
}

/// Component marking entities with network-specific snapshot requirements
#[derive(Component)]
pub struct NetworkSnapshotable {
    /// Which player IDs can see this entity
    pub visible_to: Vec<u64>,
    /// Priority for synchronization (higher values sync first)
    pub sync_priority: u8,
}
}

Plugin Implementation

The integration plugin adds networking-specific systems:

#![allow(unused)]
fn main() {
impl Plugin for NetworkSnapshotPlugin {
    fn build(&self, app: &mut App) {
        app
            // Register resources
            .init_resource::<NetworkSnapshotConfig>()
            .init_resource::<PendingNetworkSnapshots>()
            
            // Register custom events
            .add_event::<NetworkSnapshotEvent>()
            
            // Add networking-specific systems
            .add_systems(Update, (
                sync_game_state_with_clients,
                handle_network_snapshot_events,
                process_incoming_snapshots,
            ).chain())
            
            // Add systems to the replicon client and server sets
            .add_systems(RepliconClientSet::Receive, receive_network_snapshot)
            .add_systems(RepliconServerSet::Send, send_network_snapshots);
    }
}
}

State Synchronization

The core of the networking integration is the state synchronization system:

#![allow(unused)]
fn main() {
fn sync_game_state_with_clients(
    mut commands: Commands,
    time: Res<Time>,
    mut last_sync: Local<f32>,
    config: Res<NetworkSnapshotConfig>,
    game_state: Res<GameState>,
    client_info: Res<ClientRegistry>,
    world: &World,
    mut network_events: EventWriter<NetworkSnapshotEvent>,
) {
    // Check if it's time for a sync
    if time.elapsed_seconds() - *last_sync < config.sync_frequency {
        return;
    }
    
    *last_sync = time.elapsed_seconds();
    
    // Create a base snapshot of the current state
    let base_snapshot = create_game_snapshot(world, &game_state);
    
    // For each connected client, create a tailored snapshot
    for client_id in client_info.connected_clients() {
        let client_snapshot = create_client_specific_snapshot(
            &base_snapshot, 
            client_id, 
            &client_info
        );
        
        // Send the snapshot to the client
        network_events.send(NetworkSnapshotEvent::SendTo(
            client_id, 
            client_snapshot
        ));
    }
}
}

Client-Specific Snapshots

The system creates tailored snapshots for each client to manage hidden information:

#![allow(unused)]
fn main() {
fn create_client_specific_snapshot(
    base_snapshot: &GameSnapshot,
    client_id: u64,
    client_info: &ClientRegistry,
) -> GameSnapshot {
    // Clone the base snapshot
    let mut client_snapshot = base_snapshot.clone();
    
    // Filter out data the client shouldn't see
    client_snapshot.game_data.retain(|entity_key, _| {
        // Check if this entity is visible to the client
        let entity = Entity::from_bits(
            u64::from_str(entity_key).unwrap_or_default()
        );
        
        is_entity_visible_to_client(entity, client_id, client_info)
    });
    
    // Modify certain data to hide information
    for (_, entity_data) in client_snapshot.game_data.iter_mut() {
        sanitize_hidden_information(entity_data, client_id, client_info);
    }
    
    client_snapshot
}

fn is_entity_visible_to_client(
    entity: Entity,
    client_id: u64,
    client_info: &ClientRegistry,
) -> bool {
    // Logic to determine if an entity should be visible to a client
    // ...
}

fn sanitize_hidden_information(
    entity_data: &mut Vec<u8>,
    client_id: u64,
    client_info: &ClientRegistry,
) {
    // Logic to modify entity data to hide sensitive information
    // ...
}
}

Network Data Transfer

The system handles sending and receiving snapshots over the network:

#![allow(unused)]
fn main() {
fn send_network_snapshots(
    mut server: ResMut<RenetServer>,
    mut pending: ResMut<PendingNetworkSnapshots>,
    config: Res<NetworkSnapshotConfig>,
) {
    // Process all pending network snapshots
    for (client_id, snapshot) in pending.outgoing.drain(..) {
        // Serialize the snapshot
        let serialized = bincode::serialize(&snapshot)
            .unwrap_or_default();
        
        // Compress if configured
        let final_data = if config.compress_network_snapshots {
            // Compression logic...
            Vec::new()
        } else {
            serialized
        };
        
        // Send to client
        server.send_message(
            client_id,
            NetworkChannel::StateSync as u8,
            final_data
        );
    }
}

fn receive_network_snapshot(
    mut client: ResMut<RenetClient>,
    mut snapshot_events: EventWriter<SnapshotEvent>,
) {
    // Check for incoming snapshot messages
    while let Some(message) = client.receive_message(NetworkChannel::StateSync as u8) {
        // Decompress if needed
        let serialized = if is_compressed(&message) {
            // Decompression logic...
            Vec::new()
        } else {
            message
        };
        
        // Deserialize the snapshot
        if let Ok(snapshot) = bincode::deserialize::<GameSnapshot>(&serialized) {
            // Apply the received snapshot
            snapshot_events.send(SnapshotEvent::Apply(snapshot.id));
        }
    }
}
}

Rollback System

The integration includes a rollback system for handling network disruptions:

/// Plugin that provides rollback functionality for network gameplay
pub struct RollbackPlugin;

impl Plugin for RollbackPlugin {
    fn build(&self, app: &mut App) {
        app
            .init_resource::<RollbackHistory>()
            .add_event::<RollbackEvent>()
            .add_systems(Update, (
                maintain_rollback_history,
                handle_rollback_events,
            ).chain());
    }
}

/// Resource that maintains a history of snapshots for rollback
#[derive(Resource, Default)]
pub struct RollbackHistory {
    /// Historical snapshots indexed by turn and phase
    pub history: HashMap<(u32, Phase), GameSnapshot>,
}

/// Event requesting a rollback to a previous state
#[derive(Event)]
pub struct RollbackEvent {
    /// The turn to roll back to
    pub turn: u32,
    /// The phase within the turn
    pub phase: Option<Phase>,
}

fn maintain_rollback_history(
    mut history: ResMut<RollbackHistory>,
    mut snapshot_events: EventReader<SnapshotProcessedEvent>,
    snapshots: Res<SnapshotRegistry>,
    config: Res<NetworkSnapshotConfig>,
) {
    // For each new snapshot, add it to the history
    for event in snapshot_events.iter() {
        if let Some(snapshot) = snapshots.get(event.id) {
            history.history.insert(
                (snapshot.turn, snapshot.phase.clone()),
                snapshot.clone()
            );
        }
    }
    
    // Prune history to maintain size limits
    if history.history.len() > config.rollback_history_size {
        // Pruning logic...
    }
}

fn handle_rollback_events(
    mut rollback_events: EventReader<RollbackEvent>,
    history: Res<RollbackHistory>,
    mut snapshot_events: EventWriter<SnapshotEvent>,
) {
    for event in rollback_events.iter() {
        // Find the snapshot to roll back to
        let target_snapshot = if let Some(phase) = &event.phase {
            // Find specific phase
            history.history.get(&(event.turn, phase.clone()))
        } else {
            // Find any snapshot from this turn
            history.history.iter()
                .find(|((turn, _), _)| *turn == event.turn)
                .map(|(_, snapshot)| snapshot)
        };
        
        // If found, apply the snapshot
        if let Some(snapshot) = target_snapshot {
            snapshot_events.send(SnapshotEvent::Apply(snapshot.id));
        }
    }
}

Deterministic Random Number Generator

The integration includes special handling for random number generation to ensure deterministic gameplay:

#![allow(unused)]
fn main() {
/// Plugin that integrates deterministic RNG with snapshots for networking
pub struct DeterministicRNGPlugin;

impl Plugin for DeterministicRNGPlugin {
    fn build(&self, app: &mut App) {
        app
            .init_resource::<NetworkedRngState>()
            .add_systems(Update, (
                capture_rng_in_snapshot,
                restore_rng_from_snapshot,
            ).chain());
    }
}

/// Resource that tracks the state of the deterministic RNG
#[derive(Resource, Serialize, Deserialize)]
pub struct NetworkedRngState {
    /// The current seed
    pub seed: u64,
    /// The number of times the RNG has been used
    pub usage_count: u64,
}

fn capture_rng_in_snapshot(
    rng_state: Res<NetworkedRngState>,
    mut create_snapshot: EventReader<SnapshotEvent>,
    mut snapshots: ResMut<SnapshotRegistry>,
) {
    for event in create_snapshot.iter() {
        if let SnapshotEvent::Take = event {
            // Find the most recent snapshot
            if let Some(snapshot) = snapshots.most_recent() {
                // Add RNG state to the snapshot
                let rng_data = bincode::serialize(&*rng_state).unwrap_or_default();
                snapshot.game_data.insert("rng_state".to_string(), rng_data);
            }
        }
    }
}

fn restore_rng_from_snapshot(
    mut rng_state: ResMut<NetworkedRngState>,
    mut apply_snapshot: EventReader<SnapshotEvent>,
    snapshots: Res<SnapshotRegistry>,
) {
    for event in apply_snapshot.iter() {
        if let SnapshotEvent::Apply(id) = event {
            // Find the snapshot
            if let Some(snapshot) = snapshots.get(*id) {
                // Restore RNG state from snapshot
                if let Some(rng_data) = snapshot.game_data.get("rng_state") {
                    if let Ok(state) = bincode::deserialize::<NetworkedRngState>(rng_data) {
                        *rng_state = state;
                    }
                }
            }
        }
    }
}
}

Testing Network Integration

Testing the networking integration with snapshots:

#![allow(unused)]
fn main() {
#[test]
fn test_network_snapshot_synchronization() {
    // Set up a test server and client
    let mut app = App::new();
    app.add_plugins((
        MinimalPlugins,
        RepliconServerPlugin,
        SnapshotPlugin,
        NetworkSnapshotPlugin,
    ));
    
    // Create a test client
    let client_id = 1;
    let mut client_registry = ClientRegistry::default();
    client_registry.register_client(client_id);
    app.insert_resource(client_registry);
    
    // Set up game state
    setup_test_game_state(&mut app);
    
    // Trigger a snapshot
    app.world.send_event(SnapshotEvent::Take);
    app.update();
    
    // Verify that a network snapshot was created
    let network_events = app.world.resource::<Events<NetworkSnapshotEvent>>();
    let mut reader = network_events.get_reader();
    
    let has_snapshot_event = reader.iter(&network_events).any(|event| {
        matches!(event, NetworkSnapshotEvent::SendTo(id, _) if *id == client_id)
    });
    
    assert!(has_snapshot_event, "Should create a network snapshot for the client");
}
}

Best Practices

When working with the snapshot and networking integration:

  1. Minimize Snapshot Size: Use the NetworkSnapshotable component to control what gets synchronized
  2. Handle Frequent Updates: Be mindful of performance impact for frequently changing components
  3. Test Network Conditions: Use simulated network conditions to test behavior under varying latency
  4. Secure Hidden Information: Carefully audit what information is sent to each client
  5. Handle Reconnections: Ensure clients that reconnect receive a complete state update
  6. Monitor Bandwidth: Keep track of snapshot sizes and network usage
  7. Implement Fallbacks: Have strategies for when snapshot synchronization fails

Next Steps

  • Testing: How to test snapshot functionality, including network integration
  • API Reference: Complete reference documentation for the snapshot system

State Persistence with bevy_persistent

This document details how to use bevy_persistent for robust game state persistence and rollback management in Rummage.

Overview

Game state persistence and rollback functionality are critical for:

  • Save/Load: Allowing players to save and resume their games
  • Undo Support: Enabling players to revert mistakes
  • Checkpoints: Creating automatic save points at important moments
  • Crash Recovery: Recovering from crashes without losing progress
  • Replay: Supporting replay functionality

bevy_persistent provides an elegant solution for these requirements by automatically handling serialization, deserialization, and file I/O.

Integration with Snapshot System

The snapshot system can be enhanced with bevy_persistent to provide automatic, reliable state persistence:

#![allow(unused)]
fn main() {
use bevy::prelude::*;
use bevy_persistent::prelude::*;
use serde::{Deserialize, Serialize};

/// A serializable snapshot of game state
#[derive(Resource, Serialize, Deserialize, Clone, Debug)]
pub struct GameSnapshot {
    /// Unique identifier for the snapshot
    pub id: Uuid,
    /// The game turn the snapshot was taken on
    pub turn: u32,
    /// The phase within the turn
    pub phase: Phase,
    /// Active player when snapshot was taken
    pub active_player: usize,
    /// Serialized game entities
    pub entities: Vec<SerializedEntity>,
    /// Timestamp when the snapshot was created
    pub timestamp: f64,
}

/// Collection of game snapshots for rollback support
#[derive(Resource, Serialize, Deserialize, Default)]
pub struct GameSnapshotCollection {
    /// Map of snapshot ID to snapshot data
    pub snapshots: HashMap<Uuid, GameSnapshot>,
    /// Order of snapshots (earliest to latest)
    pub history: Vec<Uuid>,
    /// Maximum number of snapshots to keep
    #[serde(skip)]
    pub max_snapshots: usize,
    /// Current snapshot ID (the active state)
    pub current_snapshot_id: Option<Uuid>,
}

// Set up persistent snapshot system in plugin
fn build_persistent_snapshots(app: &mut App) {
    // Create persistent snapshot collection
    let persistent_snapshots = Persistent::<GameSnapshotCollection>::builder()
        .name("game_snapshots")
        .format(StorageFormat::Bincode) // More efficient for large state
        .path("user://snapshots.bin")
        .default(GameSnapshotCollection {
            snapshots: HashMap::new(),
            history: Vec::new(),
            max_snapshots: 50, // Keep last 50 snapshots
            current_snapshot_id: None,
        })
        .build();

    app.insert_resource(persistent_snapshots)
        .add_systems(Update, auto_save_snapshots)
        .add_systems(Startup, load_snapshot_history);
}
}

Creating Snapshots

The system for creating snapshots integrates with bevy_persistent:

#![allow(unused)]
fn main() {
/// Create a new game state snapshot
fn create_snapshot(
    world: &mut World,
    mut snapshots: ResMut<Persistent<GameSnapshotCollection>>,
) -> Uuid {
    // Get relevant game state information
    let turn = world.resource::<TurnManager>().current_turn;
    let phase = *world.resource::<Phase>();
    let active_player = world.resource::<TurnManager>().active_player;
    
    // Create snapshot ID
    let id = Uuid::new_v4();
    
    // Serialize entities
    let entities = serialize_game_entities(world);
    
    // Create snapshot
    let snapshot = GameSnapshot {
        id,
        turn,
        phase,
        active_player,
        entities,
        timestamp: world.resource::<Time>().elapsed_seconds_f64(),
    };
    
    // Add to collection
    snapshots.snapshots.insert(id, snapshot);
    snapshots.history.push(id);
    snapshots.current_snapshot_id = Some(id);
    
    // Prune old snapshots if needed
    prune_old_snapshots(&mut snapshots);
    
    // Trigger save
    if let Err(err) = snapshots.save() {
        error!("Failed to save game snapshot: {}", err);
    }
    
    id
}
}

Loading and Applying Snapshots

To restore a game state from a snapshot:

#![allow(unused)]
fn main() {
/// Load a specific snapshot by ID
fn load_snapshot(
    world: &mut World,
    snapshot_id: Uuid,
) -> Result<(), String> {
    // Get snapshot collection
    let snapshots = world.resource::<Persistent<GameSnapshotCollection>>();
    
    // Find the snapshot
    let snapshot = match snapshots.snapshots.get(&snapshot_id) {
        Some(snapshot) => snapshot,
        None => return Err(format!("Snapshot with ID {} not found", snapshot_id)),
    };
    
    // Apply the snapshot to the world
    apply_snapshot_to_world(world, snapshot)?;
    
    // Update current snapshot ID
    let mut snapshots = world.resource_mut::<Persistent<GameSnapshotCollection>>();
    snapshots.current_snapshot_id = Some(snapshot_id);
    
    info!("Loaded snapshot from turn {} (ID: {})", snapshot.turn, snapshot_id);
    
    Ok(())
}

/// Apply a snapshot to the world
fn apply_snapshot_to_world(
    world: &mut World,
    snapshot: &GameSnapshot,
) -> Result<(), String> {
    // First, clean up existing entities that should be replaced
    let entities_to_despawn = get_entities_to_despawn(world);
    for entity in entities_to_despawn {
        world.despawn(entity);
    }
    
    // Restore serialized entities
    for serialized_entity in &snapshot.entities {
        deserialize_and_spawn_entity(world, serialized_entity)?;
    }
    
    // Restore global resources
    let mut turn_manager = world.resource_mut::<TurnManager>();
    turn_manager.current_turn = snapshot.turn;
    turn_manager.active_player = snapshot.active_player;
    
    let mut phase = world.resource_mut::<Phase>();
    *phase = snapshot.phase;
    
    Ok(())
}
}

Automatic Checkpoint System

Game state can be automatically saved at key moments:

#![allow(unused)]
fn main() {
/// Create checkpoints at key moments in the game
fn checkpoint_system(
    mut commands: Commands,
    world: &mut World,
    mut snapshots: ResMut<Persistent<GameSnapshotCollection>>,
    turn_events: EventReader<TurnStartEvent>,
) {
    // Create checkpoints at the start of each turn
    for event in turn_events.iter() {
        info!("Creating checkpoint at start of turn {}", event.turn);
        let snapshot_id = create_snapshot(world, snapshots.reborrow());
        info!("Created checkpoint with ID: {}", snapshot_id);
    }
}
}

Rollback System

The rollback system allows reverting to previous states:

#![allow(unused)]
fn main() {
/// Roll back to a previous state
fn rollback_system(
    mut commands: Commands,
    mut world: &mut World,
    rollback_events: EventReader<RollbackEvent>,
    mut snapshots: ResMut<Persistent<GameSnapshotCollection>>,
) {
    for event in rollback_events.iter() {
        match event {
            RollbackEvent::ToTurn(turn) => {
                // Find the snapshot for this turn
                if let Some(snapshot_id) = find_snapshot_for_turn(*turn, &snapshots) {
                    if let Err(err) = load_snapshot(world, snapshot_id) {
                        error!("Failed to roll back to turn {}: {}", turn, err);
                    } else {
                        info!("Successfully rolled back to turn {}", turn);
                    }
                } else {
                    error!("No snapshot found for turn {}", turn);
                }
            },
            RollbackEvent::ToPreviousTurn => {
                // Roll back one turn
                if let Some(current_id) = snapshots.current_snapshot_id {
                    if let Some(prev_id) = get_previous_snapshot_id(current_id, &snapshots) {
                        if let Err(err) = load_snapshot(world, prev_id) {
                            error!("Failed to roll back to previous turn: {}", err);
                        } else {
                            info!("Successfully rolled back to previous turn");
                        }
                    } else {
                        error!("No previous snapshot found");
                    }
                }
            },
            // Other rollback types...
        }
    }
}
}

Save/Load Game Interface

A user interface for saving and loading games:

#![allow(unused)]
fn main() {
/// Save the current game with a custom name
fn save_game(
    world: &mut World,
    name: &str,
) -> Result<(), String> {
    // Create a snapshot of the current state
    let snapshots = world.resource_mut::<Persistent<GameSnapshotCollection>>();
    let snapshot_id = create_snapshot(world, snapshots);
    
    // Create a named save file
    let save_data = Persistent::<NamedGameSave>::builder()
        .name(name)
        .format(StorageFormat::Bincode)
        .path(format!("user://saves/{}.save", name))
        .default(NamedGameSave {
            name: name.to_string(),
            snapshot_id,
            created_at: chrono::Utc::now(),
            game_info: extract_game_info(world),
        })
        .build();
    
    // Save the file
    if let Err(err) = save_data.save() {
        return Err(format!("Failed to save game: {}", err));
    }
    
    info!("Game saved successfully as '{}'", name);
    Ok(())
}

/// Load a saved game by name
fn load_game(
    world: &mut World,
    name: &str,
) -> Result<(), String> {
    // Load the save file
    let save_data = Persistent::<NamedGameSave>::builder()
        .name(name)
        .format(StorageFormat::Bincode)
        .path(format!("user://saves/{}.save", name))
        .build();
    
    if let Err(err) = save_data.load() {
        return Err(format!("Failed to load save file '{}': {}", name, err));
    }
    
    // Load the snapshot
    load_snapshot(world, save_data.snapshot_id)
}
}

Crash Recovery

Automatic recovery from crashes using persistent snapshots:

#![allow(unused)]
fn main() {
/// System to check for and recover from crashes
fn crash_recovery_system(
    world: &mut World,
    mut snapshots: ResMut<Persistent<GameSnapshotCollection>>,
) {
    // Check for crash indicator file
    if std::path::Path::new("user://crash_indicator").exists() {
        warn!("Detected previous crash, attempting recovery");
        
        // Try to load snapshots
        if let Err(err) = snapshots.load() {
            error!("Failed to load snapshots during crash recovery: {}", err);
            return;
        }
        
        // Find the most recent valid snapshot
        if let Some(latest_id) = snapshots.history.last().copied() {
            info!("Recovering from snapshot {}", latest_id);
            if let Err(err) = load_snapshot(world, latest_id) {
                error!("Failed to recover from crash: {}", err);
            } else {
                info!("Successfully recovered from crash");
            }
        } else {
            error!("No snapshots available for crash recovery");
        }
        
        // Remove crash indicator
        if let Err(err) = std::fs::remove_file("user://crash_indicator") {
            error!("Failed to remove crash indicator: {}", err);
        }
    }
    
    // Create crash indicator file
    if let Err(err) = std::fs::write("user://crash_indicator", "1") {
        error!("Failed to create crash indicator: {}", err);
    }
}
}

Hot Reload Support

bevy_persistent supports hot reloading for development and testing:

#![allow(unused)]
fn main() {
/// Enable hot reloading of snapshots during development
fn setup_hot_reload(
    app: &mut App,
    snapshots: Persistent<GameSnapshotCollection>,
) {
    #[cfg(debug_assertions)]
    {
        let snapshot_path = snapshots.path().unwrap().to_path_buf();
        app.add_systems(Update, move |world: &mut World| {
            // Check if the file has been modified
            if snapshot_path_modified(&snapshot_path) {
                info!("Detected external changes to snapshot file, reloading");
                let mut snapshots = world.resource_mut::<Persistent<GameSnapshotCollection>>();
                if let Err(err) = snapshots.load() {
                    error!("Failed to hot reload snapshots: {}", err);
                } else {
                    info!("Successfully hot reloaded snapshots");
                }
            }
        });
    }
}
}

Benefits of bevy_persistent for State Management

Using bevy_persistent for state management offers several key advantages:

  1. Atomicity: Save operations are atomic, reducing the risk of corruption
  2. Error Handling: Comprehensive error handling for all I/O operations
  3. Versioning: Support for schema versioning when state structure changes
  4. Format Flexibility: Support for multiple serialization formats
  5. Hot Reloading: Ability to detect and reload changes at runtime
  6. Cross-Platform: Works consistently across all supported platforms
  7. Performance: Efficient serialization with bincode for large states

Snapshot System Testing

This document covers testing approaches and strategies for the Rummage snapshot system. For a general overview of testing in Rummage, see the Testing Overview.

Types of Tests

The snapshot system should be tested at several levels:

  1. Unit Tests: Isolated tests of individual snapshot components and functions
  2. Integration Tests: Tests of snapshot system interaction with other game systems
  3. End-to-End Tests: Tests of complete game scenarios using snapshots
  4. Performance Tests: Tests of snapshot system performance characteristics
  5. Network Tests: Tests of snapshot integration with networking

Unit Testing

Unit tests focus on individual components of the snapshot system:

#![allow(unused)]
fn main() {
#[test]
fn test_snapshot_creation() {
    // Set up a minimal app
    let mut app = App::new();
    app.add_plugins(MinimalPlugins)
       .add_plugin(SnapshotPlugin);
    
    // Add some test entities
    let entity1 = app.world.spawn((Snapshotable, TestComponent { value: 42 })).id();
    let entity2 = app.world.spawn((Snapshotable, TestComponent { value: 123 })).id();
    
    // Create a game state
    let game_state = GameState {
        turn: 1,
        phase: Phase::Main1,
        active_player: 0,
    };
    app.insert_resource(game_state);
    
    // Create a snapshot
    app.world.send_event(SnapshotEvent::Take);
    app.update();
    
    // Verify the snapshot was created
    let snapshot_registry = app.world.resource::<SnapshotRegistry>();
    assert_eq!(snapshot_registry.snapshots.len(), 1, "Should create one snapshot");
    
    // Verify the snapshot contents
    let snapshot = snapshot_registry.most_recent().unwrap();
    assert_eq!(snapshot.turn, 1, "Snapshot should have the correct turn");
    assert_eq!(snapshot.phase, Phase::Main1, "Snapshot should have the correct phase");
    assert_eq!(snapshot.active_player, 0, "Snapshot should have the correct active player");
    
    // Verify entities were captured
    assert_eq!(snapshot.game_data.len(), 2, "Snapshot should include 2 entities");
}

#[test]
fn test_snapshot_application() {
    // Set up a minimal app
    let mut app = App::new();
    app.add_plugins(MinimalPlugins)
       .add_plugin(SnapshotPlugin);
    
    // Add some test entities
    let entity1 = app.world.spawn((Snapshotable, TestComponent { value: 42 })).id();
    
    // Create a game state
    let game_state = GameState {
        turn: 1,
        phase: Phase::Main1,
        active_player: 0,
    };
    app.insert_resource(game_state);
    
    // Create a snapshot
    app.world.send_event(SnapshotEvent::Take);
    app.update();
    
    // Get the snapshot ID
    let snapshot_id = app.world.resource::<SnapshotRegistry>()
                             .most_recent().unwrap().id;
    
    // Modify the entity
    if let Some(mut test_comp) = app.world.get_mut::<TestComponent>(entity1) {
        test_comp.value = 99;
    }
    
    // Apply the snapshot
    app.world.send_event(SnapshotEvent::Apply(snapshot_id));
    app.update();
    
    // Verify the entity was restored to its original state
    let test_comp = app.world.get::<TestComponent>(entity1).unwrap();
    assert_eq!(test_comp.value, 42, "Component should be restored to original value");
}
}

Integration Testing

Integration tests verify how snapshots interact with other game systems:

#![allow(unused)]
fn main() {
#[test]
fn test_snapshot_with_turn_system() {
    // Set up a test app with relevant plugins
    let mut app = App::new();
    app.add_plugins((
        MinimalPlugins,
        SnapshotPlugin,
        TurnSystemPlugin,
    ));
    
    // Configure auto-snapshots
    let mut config = SnapshotConfig::default();
    config.auto_snapshot_on_turn = true;
    app.insert_resource(config);
    
    // Set up initial game state
    app.insert_resource(GameState {
        turn: 1,
        phase: Phase::Main1,
        active_player: 0,
    });
    
    // Add some test entities
    app.world.spawn((Snapshotable, TestComponent { value: 42 }));
    
    // Advance the turn
    app.world.send_event(AdvanceTurnEvent);
    app.update();
    
    // Verify a snapshot was automatically created
    let snapshot_registry = app.world.resource::<SnapshotRegistry>();
    assert_eq!(snapshot_registry.snapshots.len(), 1, "Should create one snapshot on turn change");
    
    // Verify the snapshot has the correct turn number
    let snapshot = snapshot_registry.most_recent().unwrap();
    assert_eq!(snapshot.turn, 2, "Snapshot should capture the new turn number");
}
}

End-to-End Testing

End-to-end tests verify complete game scenarios:

#![allow(unused)]
fn main() {
#[test]
fn test_full_game_with_snapshots() {
    // Set up a complete game environment
    let mut app = App::new();
    app.add_plugins((
        DefaultPlugins,
        GameEnginePlugin,
        SnapshotPlugin,
    ));
    
    // Set up a test game
    setup_test_game(&mut app);
    
    // Play through multiple turns, taking snapshots
    for turn in 1..5 {
        // Play through a turn
        play_turn(&mut app);
        
        // Take a snapshot
        app.world.send_event(SnapshotEvent::Take);
        app.update();
    }
    
    // Verify we have snapshots for each turn
    let snapshot_registry = app.world.resource::<SnapshotRegistry>();
    assert_eq!(snapshot_registry.snapshots.len(), 4, "Should have 4 snapshots");
    
    // Go back to turn 2
    let turn_2_snapshot = snapshot_registry.snapshots.values()
                                          .find(|s| s.turn == 2)
                                          .unwrap();
    app.world.send_event(SnapshotEvent::Apply(turn_2_snapshot.id));
    app.update();
    
    // Verify game state was restored correctly
    let game_state = app.world.resource::<GameState>();
    assert_eq!(game_state.turn, 2, "Game should be restored to turn 2");
    
    // Continue playing from this restored state
    play_turn(&mut app);
    
    // Verify the game progressed correctly from the restored state
    let game_state = app.world.resource::<GameState>();
    assert_eq!(game_state.turn, 3, "Game should advance to turn 3");
}
}

Performance Testing

Performance tests measure the impact of snapshots:

#![allow(unused)]
fn main() {
use criterion::{black_box, criterion_group, criterion_main, Criterion};

fn snapshot_creation_benchmark(c: &mut Criterion) {
    c.bench_function("create snapshot with 100 entities", |b| {
        // Set up a test app
        let mut app = App::new();
        app.add_plugins((MinimalPlugins, SnapshotPlugin));
        
        // Add 100 test entities
        for i in 0..100 {
            app.world.spawn((Snapshotable, TestComponent { value: i }));
        }
        
        b.iter(|| {
            // Create a snapshot
            app.world.send_event(SnapshotEvent::Take);
            app.update();
            
            // Clear the snapshots for the next iteration
            app.world.resource_mut::<SnapshotRegistry>().snapshots.clear();
        });
    });
}

fn snapshot_application_benchmark(c: &mut Criterion) {
    c.bench_function("apply snapshot with 100 entities", |b| {
        // Set up a test app
        let mut app = App::new();
        app.add_plugins((MinimalPlugins, SnapshotPlugin));
        
        // Add 100 test entities
        for i in 0..100 {
            app.world.spawn((Snapshotable, TestComponent { value: i }));
        }
        
        // Create a snapshot
        app.world.send_event(SnapshotEvent::Take);
        app.update();
        
        // Get the snapshot ID
        let snapshot_id = app.world.resource::<SnapshotRegistry>()
                               .most_recent().unwrap().id;
        
        b.iter(|| {
            // Apply the snapshot
            app.world.send_event(SnapshotEvent::Apply(snapshot_id));
            app.update();
        });
    });
}

criterion_group!(
    snapshot_benches,
    snapshot_creation_benchmark,
    snapshot_application_benchmark
);
criterion_main!(snapshot_benches);
}

Network Testing

Testing snapshot integration with networking:

#![allow(unused)]
fn main() {
#[test]
fn test_network_snapshot_sync() {
    // Set up a server app
    let mut server_app = App::new();
    server_app.add_plugins((
        MinimalPlugins,
        RepliconServerPlugin,
        SnapshotPlugin,
        NetworkSnapshotPlugin,
    ));
    
    // Set up a client app
    let mut client_app = App::new();
    client_app.add_plugins((
        MinimalPlugins,
        RepliconClientPlugin,
        SnapshotPlugin,
        NetworkSnapshotPlugin,
    ));
    
    // Connect the client to the server
    let client_id = connect_client_to_server(&mut server_app, &mut client_app);
    
    // Set up game state on the server
    setup_test_game(&mut server_app);
    
    // Trigger a snapshot and network sync
    server_app.world.send_event(SnapshotEvent::Take);
    server_app.update();
    
    // Run multiple updates to allow for network processing
    for _ in 0..10 {
        server_app.update();
        client_app.update();
    }
    
    // Verify the client received and applied the snapshot
    let client_state = client_app.world.resource::<GameState>();
    let server_state = server_app.world.resource::<GameState>();
    
    assert_eq!(client_state.turn, server_state.turn, "Client turn should match server");
    assert_eq!(client_state.phase, server_state.phase, "Client phase should match server");
}
}

Testing Deterministic RNG

Tests for RNG integration with snapshots:

#![allow(unused)]
fn main() {
#[test]
fn test_rng_snapshot_determinism() {
    // Set up a test app
    let mut app = App::new();
    app.add_plugins((
        MinimalPlugins,
        SnapshotPlugin,
        DeterministicRNGPlugin,
    ));
    
    // Initialize RNG with a known seed
    app.insert_resource(NetworkedRngState {
        seed: 12345,
        usage_count: 0,
    });
    
    // Create an RNG and generate some random numbers
    let mut rng = app.world.resource_mut::<NetworkedRngState>().create_rng();
    let first_values: Vec<u32> = (0..10).map(|_| rng.gen::<u32>()).collect();
    
    // Take a snapshot
    app.world.send_event(SnapshotEvent::Take);
    app.update();
    
    // Get the snapshot ID
    let snapshot_id = app.world.resource::<SnapshotRegistry>()
                           .most_recent().unwrap().id;
    
    // Generate more random numbers (changing the RNG state)
    let mut rng = app.world.resource_mut::<NetworkedRngState>().create_rng();
    for _ in 0..20 {
        rng.gen::<u32>();
    }
    
    // Apply the snapshot to restore the RNG state
    app.world.send_event(SnapshotEvent::Apply(snapshot_id));
    app.update();
    
    // Generate random numbers again from the restored state
    let mut rng = app.world.resource_mut::<NetworkedRngState>().create_rng();
    let restored_values: Vec<u32> = (0..10).map(|_| rng.gen::<u32>()).collect();
    
    // Verify the sequences are identical
    assert_eq!(first_values, restored_values, "RNG sequences should be identical after snapshot restoration");
}
}

Test Fixtures

Creating reusable test fixtures:

#![allow(unused)]
fn main() {
/// Sets up a basic test environment for snapshot testing
fn setup_snapshot_test_environment() -> App {
    let mut app = App::new();
    app.add_plugins((
        MinimalPlugins,
        SnapshotPlugin,
    ));
    
    // Add test entities
    app.world.spawn((Snapshotable, TestComponent { value: 1 }));
    app.world.spawn((Snapshotable, TestComponent { value: 2 }));
    app.world.spawn((Snapshotable, TestComponent { value: 3 }));
    
    // Add game state
    app.insert_resource(GameState {
        turn: 1,
        phase: Phase::Main1,
        active_player: 0,
    });
    
    app
}

/// Creates a snapshot and returns the snapshot ID
fn create_test_snapshot(app: &mut App) -> Uuid {
    app.world.send_event(SnapshotEvent::Take);
    app.update();
    
    app.world.resource::<SnapshotRegistry>()
             .most_recent()
             .unwrap()
             .id
}

/// Test component for snapshot tests
#[derive(Component, Clone, PartialEq, Debug, Serialize, Deserialize)]
struct TestComponent {
    value: i32,
}
}

Mocking Dependencies

Using mocks for testing:

#![allow(unused)]
fn main() {
/// Mock game state for testing
#[derive(Resource, Clone, Debug, Default)]
struct MockGameState {
    turn: u32,
    phase: Phase,
    active_player: usize,
}

/// Mock event for testing auto-snapshots
#[derive(Event)]
struct MockTurnChangeEvent;

/// Test that snapshots are triggered by events using mocks
#[test]
fn test_snapshot_event_triggers() {
    // Set up a test app
    let mut app = App::new();
    app.add_plugins(MinimalPlugins)
       .add_plugin(SnapshotPlugin)
       .add_event::<MockTurnChangeEvent>();
    
    // Add a system that listens for MockTurnChangeEvent and triggers snapshots
    app.add_systems(Update, |
        mut turn_events: EventReader<MockTurnChangeEvent>,
        mut snapshot_events: EventWriter<SnapshotEvent>,
    | {
        for _ in turn_events.iter() {
            snapshot_events.send(SnapshotEvent::Take);
        }
    });
    
    // Send a mock turn change event
    app.world.send_event(MockTurnChangeEvent);
    app.update();
    
    // Verify a snapshot was created
    let snapshot_registry = app.world.resource::<SnapshotRegistry>();
    assert_eq!(snapshot_registry.snapshots.len(), 1, "Should create a snapshot in response to the event");
}
}

Best Practices

When testing the snapshot system:

  1. Isolate Tests: Each test should focus on a specific aspect of the snapshot system
  2. Use Test Fixtures: Create reusable setups for snapshot testing
  3. Test Error Handling: Verify behavior when snapshots fail or are corrupted
  4. Measure Performance: Track performance metrics for snapshot operations
  5. Test Edge Cases:
    • Empty snapshots
    • Very large snapshots
    • Concurrent snapshot operations
    • Snapshot application during state changes
  6. Test Integration Points: Verify all systems that interact with snapshots
  7. Use Mocks: Create mock implementations of dependencies for focused testing

Next Steps

  • API Reference: Complete reference documentation for the snapshot system

Snapshot System API Reference

This document provides a comprehensive reference for the Snapshot System API in Rummage.

Core Types

GameSnapshot

The primary data structure for storing game state:

#![allow(unused)]
fn main() {
/// A serializable snapshot of the game state
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct GameSnapshot {
    /// Unique identifier for the snapshot
    pub id: Uuid,
    /// The game turn the snapshot was taken on
    pub turn: u32,
    /// The phase within the turn
    pub phase: Phase,
    /// Active player when snapshot was taken
    pub active_player: usize,
    /// Serialized game data
    pub game_data: HashMap<String, Vec<u8>>,
    /// Timestamp when the snapshot was created
    pub timestamp: f64,
}

impl GameSnapshot {
    /// Creates a new empty snapshot
    pub fn new(turn: u32, phase: Phase, active_player: usize) -> Self;
    
    /// Returns the size of the snapshot in bytes
    pub fn size_bytes(&self) -> usize;
    
    /// Checks if the snapshot contains a specific entity
    pub fn contains_entity(&self, entity: Entity) -> bool;
    
    /// Gets the serialized data for a specific entity
    pub fn get_entity_data(&self, entity: Entity) -> Option<&Vec<u8>>;
}
}

SnapshotRegistry

Resource for managing snapshots:

#![allow(unused)]
fn main() {
/// Manages snapshots in memory
#[derive(Resource, Default)]
pub struct SnapshotRegistry {
    /// All available snapshots indexed by ID
    pub snapshots: HashMap<Uuid, GameSnapshot>,
}

impl SnapshotRegistry {
    /// Returns the most recent snapshot
    pub fn most_recent(&self) -> Option<&GameSnapshot>;
    
    /// Returns a snapshot by ID
    pub fn get(&self, id: Uuid) -> Option<&GameSnapshot>;
    
    /// Adds a snapshot to the registry
    pub fn add(&mut self, snapshot: GameSnapshot);
    
    /// Removes a snapshot from the registry
    pub fn remove(&mut self, id: Uuid) -> Option<GameSnapshot>;
    
    /// Clears all snapshots
    pub fn clear(&mut self);
    
    /// Returns all snapshots sorted by timestamp
    pub fn all_sorted_by_time(&self) -> Vec<&GameSnapshot>;
    
    /// Finds snapshots for a specific turn
    pub fn find_by_turn(&self, turn: u32) -> Vec<&GameSnapshot>;
}
}

PendingSnapshots

Resource for tracking snapshot operations:

#![allow(unused)]
fn main() {
/// Tracks pending snapshot operations
#[derive(Resource, Default)]
pub struct PendingSnapshots {
    /// Snapshots waiting to be processed
    pub queue: VecDeque<GameSnapshot>,
    /// Whether snapshot processing is paused
    pub paused: bool,
}

impl PendingSnapshots {
    /// Adds a snapshot to the queue
    pub fn enqueue(&mut self, snapshot: GameSnapshot);
    
    /// Gets the next snapshot from the queue
    pub fn dequeue(&mut self) -> Option<GameSnapshot>;
    
    /// Pauses snapshot processing
    pub fn pause(&mut self);
    
    /// Resumes snapshot processing
    pub fn resume(&mut self);
    
    /// Clears the queue
    pub fn clear(&mut self);
}
}

SnapshotConfig

Configuration for the snapshot system:

#![allow(unused)]
fn main() {
/// Configuration for the snapshot system
#[derive(Resource)]
pub struct SnapshotConfig {
    /// Whether to automatically create snapshots on turn changes
    pub auto_snapshot_on_turn: bool,
    /// Whether to automatically create snapshots on phase changes
    pub auto_snapshot_on_phase: bool,
    /// Maximum number of snapshots to process per frame
    pub max_snapshots_per_frame: usize,
    /// Maximum number of snapshots to keep in history
    pub max_history_size: usize,
    /// Whether to compress snapshots
    pub use_compression: bool,
}

impl Default for SnapshotConfig {
    fn default() -> Self {
        Self {
            auto_snapshot_on_turn: true,
            auto_snapshot_on_phase: false,
            max_snapshots_per_frame: 1,
            max_history_size: 100,
            use_compression: true,
        }
    }
}
}

Components

Snapshotable

Marker component for entities that should be included in snapshots:

#![allow(unused)]
fn main() {
/// Marker for entities that should be included in snapshots
#[derive(Component)]
pub struct Snapshotable;
}

SnapshotExcluded

Marker component for components that should not be included in snapshots:

#![allow(unused)]
fn main() {
/// Marker for components that should be excluded from snapshots
#[derive(Component)]
pub struct SnapshotExcluded;
}

Events

SnapshotEvent

Events for controlling snapshot operations:

#![allow(unused)]
fn main() {
/// Events for snapshot operations
#[derive(Event)]
pub enum SnapshotEvent {
    /// Take a new snapshot of the current state
    Take,
    /// Apply a specific snapshot
    Apply(Uuid),
    /// Save the current snapshot to disk
    Save(String),
    /// Load a snapshot from disk
    Load(String),
}
}

SnapshotProcessedEvent

Event for snapshot processing completion:

#![allow(unused)]
fn main() {
/// Event fired when a snapshot has been processed
#[derive(Event)]
pub struct SnapshotProcessedEvent {
    /// The ID of the processed snapshot
    pub id: Uuid,
    /// Whether processing succeeded
    pub success: bool,
    /// Error message if processing failed
    pub error: Option<String>,
}
}

Plugin

SnapshotPlugin

Main plugin for the snapshot system:

#![allow(unused)]
fn main() {
pub struct SnapshotPlugin;

impl Plugin for SnapshotPlugin {
    fn build(&self, app: &mut App) {
        app
            // Register resources
            .init_resource::<PendingSnapshots>()
            .init_resource::<SnapshotConfig>()
            .init_resource::<SnapshotRegistry>()
            
            // Register snapshot events
            .add_event::<SnapshotEvent>()
            .add_event::<SnapshotProcessedEvent>()
            
            // Add snapshot systems to the appropriate schedule
            .add_systems(Update, (
                handle_snapshot_events,
                process_pending_snapshots,
                trigger_snapshot_on_turn_change,
                trigger_snapshot_on_phase_change,
                cleanup_old_snapshots,
            ).chain());
    }
}
}

Systems

Handle Snapshot Events

#![allow(unused)]
fn main() {
/// Handles snapshot events
pub fn handle_snapshot_events(
    mut commands: Commands,
    mut snapshot_events: EventReader<SnapshotEvent>,
    mut pending: ResMut<PendingSnapshots>,
    mut snapshot_registry: ResMut<SnapshotRegistry>,
    mut processed_events: EventWriter<SnapshotProcessedEvent>,
    game_state: Res<GameState>,
    time: Res<Time>,
    world: &World,
) {
    // Implementation details...
}
}

Process Pending Snapshots

#![allow(unused)]
fn main() {
/// Process any pending snapshots in the queue
pub fn process_pending_snapshots(
    mut commands: Commands,
    mut pending: ResMut<PendingSnapshots>,
    mut processed_events: EventWriter<SnapshotProcessedEvent>,
    config: Res<SnapshotConfig>,
) {
    // Implementation details...
}
}

Automatic Snapshot Triggers

#![allow(unused)]
fn main() {
/// Triggers a snapshot when the turn changes
pub fn trigger_snapshot_on_turn_change(
    mut turn_events: EventReader<TurnChangeEvent>,
    mut snapshot_events: EventWriter<SnapshotEvent>,
    config: Res<SnapshotConfig>,
) {
    // Implementation details...
}

/// Triggers a snapshot when the phase changes
pub fn trigger_snapshot_on_phase_change(
    mut phase_events: EventReader<PhaseChangeEvent>,
    mut snapshot_events: EventWriter<SnapshotEvent>,
    config: Res<SnapshotConfig>,
) {
    // Implementation details...
}
}

Cleanup System

#![allow(unused)]
fn main() {
/// Cleans up old snapshots to maintain the history size limit
pub fn cleanup_old_snapshots(
    mut snapshot_registry: ResMut<SnapshotRegistry>,
    config: Res<SnapshotConfig>,
) {
    // Implementation details...
}
}

Functions

Create Snapshot

#![allow(unused)]
fn main() {
/// Creates a complete snapshot of the current game state
pub fn create_game_snapshot(
    world: &World,
    game_state: &GameState,
) -> GameSnapshot {
    // Implementation details...
}
}

Serialize Entity

#![allow(unused)]
fn main() {
/// Serializes an entity to a snapshot
pub fn serialize_entity_to_snapshot(
    world: &World,
    entity: Entity,
    snapshot: &mut GameSnapshot,
) {
    // Implementation details...
}
}

Apply Snapshot

#![allow(unused)]
fn main() {
/// Applies a snapshot to restore game state
pub fn apply_snapshot(
    commands: &mut Commands,
    snapshot: &GameSnapshot,
) {
    // Implementation details...
}
}

Save and Load

#![allow(unused)]
fn main() {
/// Saves a snapshot to disk
pub fn save_snapshot_to_disk(
    snapshot: &GameSnapshot,
    path: &str,
) -> Result<(), std::io::Error> {
    // Implementation details...
}

/// Loads a snapshot from disk
pub fn load_snapshot_from_disk(
    path: &str,
) -> Result<GameSnapshot, std::io::Error> {
    // Implementation details...
}
}

Networking Integration

NetworkSnapshotPlugin

#![allow(unused)]
fn main() {
/// Plugin that integrates the snapshot system with networking
pub struct NetworkSnapshotPlugin;

impl Plugin for NetworkSnapshotPlugin {
    fn build(&self, app: &mut App) {
        // Implementation details...
    }
}
}

NetworkSnapshotConfig

#![allow(unused)]
fn main() {
/// Resource that tracks network-specific snapshot configuration
#[derive(Resource)]
pub struct NetworkSnapshotConfig {
    /// Frequency of network snapshot updates (in seconds)
    pub sync_frequency: f32,
    /// Maximum size of a snapshot packet (in bytes)
    pub max_packet_size: usize,
    /// Whether to compress network snapshots
    pub compress_network_snapshots: bool,
    /// Number of snapshots to keep for rollback purposes
    pub rollback_history_size: usize,
}
}

NetworkSnapshotable

#![allow(unused)]
fn main() {
/// Component marking entities with network-specific snapshot requirements
#[derive(Component)]
pub struct NetworkSnapshotable {
    /// Which player IDs can see this entity
    pub visible_to: Vec<u64>,
    /// Priority for synchronization (higher values sync first)
    pub sync_priority: u8,
}
}

Usage Examples

Basic Usage

#![allow(unused)]
fn main() {
// Add the snapshot plugin to your app
app.add_plugins(SnapshotPlugin);

// Configure the snapshot system
let mut config = SnapshotConfig::default();
config.auto_snapshot_on_turn = true;
config.max_history_size = 50;
app.insert_resource(config);

// Mark entities for snapshot inclusion
commands.spawn((
    Snapshotable,
    MyComponent { value: 42 },
));

// Manually create a snapshot
app.world.send_event(SnapshotEvent::Take);

// Apply a snapshot
let snapshot_id = app.world.resource::<SnapshotRegistry>()
                         .most_recent().unwrap().id;
app.world.send_event(SnapshotEvent::Apply(snapshot_id));

// Save a snapshot to disk
app.world.send_event(SnapshotEvent::Save("save_game_1.snapshot".to_string()));

// Load a snapshot from disk
app.world.send_event(SnapshotEvent::Load("save_game_1.snapshot".to_string()));
}

Network Integration

#![allow(unused)]
fn main() {
// Add the network snapshot plugin
app.add_plugins((
    SnapshotPlugin,
    NetworkSnapshotPlugin,
));

// Configure network snapshot settings
let mut network_config = NetworkSnapshotConfig::default();
network_config.sync_frequency = 0.1; // 10 updates per second
network_config.compress_network_snapshots = true;
app.insert_resource(network_config);

// Mark entities for network visibility
commands.spawn((
    Snapshotable,
    NetworkSnapshotable {
        visible_to: vec![1, 2], // Only visible to players 1 and 2
        sync_priority: 10,      // High priority
    },
    MyComponent { value: 42 },
));
}

Rollback Usage

#![allow(unused)]
fn main() {
// Add rollback plugin
app.add_plugins((
    SnapshotPlugin,
    NetworkSnapshotPlugin,
    RollbackPlugin,
));

// Trigger a rollback to a specific turn
app.world.send_event(RollbackEvent {
    turn: 5,
    phase: Some(Phase::Combat),
});
}

Common Patterns

Snapshot Listener

#![allow(unused)]
fn main() {
fn my_snapshot_listener(
    mut snapshot_events: EventReader<SnapshotProcessedEvent>,
) {
    for event in snapshot_events.iter() {
        println!("Snapshot processed: {}", event.id);
        if !event.success {
            if let Some(error) = &event.error {
                println!("Error: {}", error);
            }
        }
    }
}
}

Custom Snapshot Trigger

#![allow(unused)]
fn main() {
fn trigger_snapshot_on_critical_event(
    mut critical_events: EventReader<CriticalGameEvent>,
    mut snapshot_events: EventWriter<SnapshotEvent>,
) {
    for event in critical_events.iter() {
        // Create a snapshot when critical events occur
        snapshot_events.send(SnapshotEvent::Take);
    }
}
}

Snapshot Analysis

#![allow(unused)]
fn main() {
fn analyze_snapshots(
    snapshot_registry: Res<SnapshotRegistry>,
) {
    let snapshots = snapshot_registry.all_sorted_by_time();
    for snapshot in snapshots {
        println!("Snapshot {} - Turn {}, Phase {:?}", 
                 snapshot.id, snapshot.turn, snapshot.phase);
    }
}
}

Testing Overview

Introduction

The Rummage MTG Commander game engine employs a comprehensive testing strategy to ensure correctness, reliability, and performance across all game mechanics and user interactions.

Core Testing Principles

Our testing framework is built on these foundational principles:

PrincipleDescriptionImplementation
Rules CorrectnessAll MTG rule implementations must be verified against official rulesRule-specific test cases with expected outcomes
DeterminismGame states must evolve consistently with the same inputsSeeded random tests, state verification
Cross-Platform ConsistencyBehavior and visuals must be identical across platformsVisual differential testing, behavior validation
PerformanceSystem must maintain responsiveness under various conditionsLoad testing, benchmarking key operations
AccessibilityFeatures must work with assistive technologiesScreen reader testing, keyboard navigation tests

Testing Pyramid

We implement a comprehensive testing pyramid with increasing scope and integration:

    ┌─────────────┐
    │   E2E &     │
    │  Visual     │
    │  Testing    │
    ├─────────────┤
    │Integration  │
    │  Testing    │
    ├─────────────┤
    │    Unit     │
    │  Testing    │
    └─────────────┘

Unit Testing

Unit tests verify isolated components, focusing on correctness at the smallest levels:

#![allow(unused)]
fn main() {
#[test]
fn test_mana_cost_parsing() {
    // Given a mana cost string
    let cost_string = "{2}{W}{W}";
    
    // When we parse it
    let cost = ManaCost::from_string(cost_string);
    
    // Then the components should be correctly parsed
    assert_eq!(cost.generic, 2);
    assert_eq!(cost.white, 2);
    assert_eq!(cost.blue, 0);
    assert_eq!(cost.black, 0);
    assert_eq!(cost.red, 0);
    assert_eq!(cost.green, 0);
}
}

Key unit test categories:

  • Component Tests: Verify individual ECS components
  • System Tests: Test isolated ECS systems
  • Rules Tests: Verify specific rule implementations
  • Parser Tests: Test card text and effect parsing
  • Utility Tests: Validate helper functions

Integration Testing

Integration tests verify interactions between multiple systems:

#![allow(unused)]
fn main() {
#[test]
fn test_creature_etb_effects() {
    // Create a test world with necessary systems
    let mut app = App::new();
    app.add_plugins(TestingPlugins)
       .add_systems(Update, (cast_spell_system, resolve_etb_effects));
    
    // Set up a creature card with an ETB effect
    let creature_entity = setup_test_creature(&mut app, "Mulldrifter");
    
    // Cast the creature spell
    let player = setup_test_player(&mut app);
    app.world.send_event(CastSpellEvent {
        card: creature_entity,
        controller: player,
        targets: Vec::new(),
    });
    
    // Run systems to resolve the spell
    app.update();
    
    // Verify the ETB effect (draw 2 cards) occurred
    let player_data = app.world.get::<PlayerData>(player).unwrap();
    assert_eq!(player_data.hand.len(), 2, "Player should have drawn 2 cards from ETB effect");
}
}

Key integration test categories:

  • Card Interactions: Test how cards affect each other
  • Game State Transitions: Verify phase and turn changes
  • Player Actions: Test sequences of player actions
  • Zone Transitions: Validate card movement between zones

End-to-End Testing

E2E tests validate complete game scenarios from start to finish:

#![allow(unused)]
fn main() {
#[test]
fn test_basic_commander_game() {
    // Initialize complete game environment
    let mut app = App::new();
    app.add_plugins(CommanderGamePlugins);
    
    // Set up two players with predefined decks
    let player1 = setup_player(&mut app, "Player1", "Atraxa_Deck.json");
    let player2 = setup_player(&mut app, "Player2", "Muldrotha_Deck.json");
    
    // Define automated sequence of player actions
    let game_script = GameScript::from_file("test_scripts/basic_commander_game.yaml");
    app.insert_resource(game_script);
    
    // Run game simulation
    while !app.world.resource::<GameState>().is_game_over {
        app.update();
    }
    
    // Verify final game state
    let game_result = app.world.resource::<GameResult>();
    assert_eq!(game_result.winner, Some(player1), "Player 1 should win this scripted game");
    assert_eq!(game_result.turn_count, 12, "Game should last 12 turns");
}
}

Key E2E test categories:

  • Game Completion: Test full games through to completion
  • Scenario Tests: Verify specific game scenarios
  • Multiplayer Tests: Validate multiplayer dynamics
  • Tournament Rules: Test format-specific rules

Visual Testing

Visual tests ensure consistent UI representation across platforms:

#![allow(unused)]
fn main() {
#[test]
fn test_card_rendering() {
    // Initialize app with rendering plugins
    let mut app = App::new();
    app.add_plugins(RenderingTestPlugins);
    
    // Create test card
    let card = setup_test_card(&mut app, "Lightning Bolt");
    
    // Render card to texture
    let texture = render_card_to_texture(&mut app, card);
    
    // Compare with reference image with tolerance for slight rendering differences
    let reference = load_reference_image("cards/lightning_bolt.png");
    let comparison = compare_images(texture, reference);
    
    assert!(comparison.similarity > 0.99, 
           "Card rendering should match reference image");
}
}

Key visual test categories:

  • Card Rendering: Verify cards appear correctly
  • UI Components: Test UI element appearance
  • Animations: Validate animation correctness
  • Layout Tests: Ensure responsive layouts work

Performance Testing

Performance tests measure system responsiveness and resource usage:

#![allow(unused)]
fn main() {
#[test]
fn benchmark_large_board_state() {
    use criterion::{black_box, criterion_group, criterion_main, Criterion};
    
    fn benchmark(c: &mut Criterion) {
        c.bench_function("100 card battlefield update", |b| {
            b.iter_batched(
                || setup_large_battlefield(100), // Setup 100 cards
                |mut app| {
                    // Measure the time for a complete update cycle
                    black_box(app.update());
                },
                criterion::BatchSize::SmallInput,
            )
        });
    }
    
    criterion_group!(benches, benchmark);
    criterion_main!(benches);
}
}

Key performance test areas:

  • Frame Rate: Measure FPS under varying load
  • Memory Usage: Track memory consumption
  • CPU Utilization: Monitor processing requirements
  • Load Scaling: Test with increasing entity counts

Snapshot Testing

Snapshot testing allows us to capture and verify game state at specific points in time, providing a powerful tool for validating complex interactions and game state transitions.

Rather than duplicating the extensive snapshot testing documentation here, please refer to the comprehensive Snapshot System Testing Documentation.

Key snapshot testing uses:

  • State Verification: Validate game state correctness
  • Regression Testing: Detect unintended changes to game behavior
  • Cross-System Testing: Verify components work together correctly
  • Replay Validation: Ensure replay system correctly reproduces game states

Network Testing

Our network testing verifies the integrity of multiplayer functionality:

  • State Synchronization: Verify game states remain synchronized across clients
  • Latency Simulation: Test behavior under varying network conditions
  • Disconnection Handling: Validate reconnection and recovery
  • Deterministic RNG: Ensure random events produce identical results across clients

Rules Compliance

Rules compliance testing verifies correct implementation of MTG rules:

  • Comprehensive Rules Coverage: Tests mapped to official rules
  • Judge Corner Cases: Special scenarios from tournament rulings
  • Rule Interactions: Tests for complex rule interactions
  • Oracle Text Tests: Validation against official card rulings

Testing Infrastructure

CI/CD Pipeline

Our continuous integration pipeline ensures ongoing quality:

  1. Pull Request Checks:

    • Fast unit tests run on every PR
    • Linting and formatting checks
    • Build verification
  2. Main Branch Validation:

    • Full test suite runs
    • Performance regression checks
    • Cross-platform test matrix
  3. Release Preparation:

    • Complete E2E and integration tests
    • Visual regression testing
    • Performance benchmarking

Test Data Management

We maintain structured test datasets:

  • Card Database: Test card definitions with expected behaviors
  • Game Scenarios: Predefined game states for testing
  • Board States: Complex battlefield configurations
  • Performance Benchmarks: Standard scenarios for consistency

Contributing Tests

To contribute new tests:

  1. Identify Testing Gap: Find an untested feature or edge case
  2. Determine Test Level: Choose appropriate test level (unit, integration, etc.)
  3. Write Test: Follow test pattern for that level
  4. Verify Coverage: Ensure test increases coverage metrics
  5. Submit PR: Include tests with implementation changes

See our contribution guidelines for more details.

Testing Best Practices

Follow these best practices when writing tests for Rummage:

  1. Test Focused Behavior: Each test should verify one specific behavior
  2. Use Clear Assertions: Make assertion messages descriptive
  3. Create Minimal Setup: Use only what's necessary for the test
  4. Use Test Abstractions: Share setup code between similar tests
  5. Test Edge Cases: Include boundary conditions and error scenarios

Testing Metrics and Goals

We track these key metrics for our test suite:

  • Code Coverage: Maintain >90% coverage for core game logic
  • Rules Coverage: Document percentage of MTG rules with dedicated tests
  • Test Performance: Keep test suite execution time under 5 minutes
  • Failure Rate: Maintain <1% flaky test ratio

Next Steps

To dive deeper into our testing approach:

Testing Strategies

Unit Tests

Integration Tests

Visual Testing

Visual testing in Rummage allows us to detect unintended visual regressions in the game's UI and rendering. By capturing screenshots during automated tests and comparing them to reference images, we can identify changes that might break the user experience.

How Visual Testing Works

  1. Test Fixture Setup: Each test creates a controlled environment with known entities and camera settings
  2. Reference Images: The system captures screenshots and compares them against reference images
  3. Difference Detection: Using image comparison algorithms, the system identifies visual differences
  4. Artifact Generation: When differences exceed a threshold, visual diffs are generated for inspection

Running Visual Tests Locally

To run the visual tests locally, use:

cargo test --package rummage --lib "tests::visual_testing::"

Updating Reference Images

If you've made intentional visual changes, update the reference images:

GENERATE_REFERENCES=1 cargo test --package rummage --lib "tests::visual_testing::"

CI Integration

Visual tests run automatically on GitHub Actions:

  1. Pull Requests: Tests run to catch visual regressions
  2. Reference Updates: When visual changes are intentional, update references with GENERATE_REFERENCES=1
  3. Artifact Inspection: Test failures produce visual diffs that can be downloaded as artifacts

Creating New Visual Tests

To create a new visual test:

  1. Create a test fixture that sets up the specific visual scenario
  2. Use request_screenshot() to capture the scene
  3. Run your test with GENERATE_REFERENCES=1 to create the initial reference images
  4. Verify the reference images match your expectations
  5. Run without the flag to ensure tests pass

Headless Rendering

For CI environments, we use Xvfb (X Virtual Framebuffer) to provide a virtual display for rendering. This allows tests to run in headless environments like GitHub Actions.

The visual testing system uses a special headless configuration with:

  • Fixed window size for deterministic rendering
  • Vulkan backend for better compatibility
  • Low power mode for CI environments
  • Invisible windows to avoid flickering on CI servers

Troubleshooting

If tests are failing unexpectedly:

  1. Download Artifacts: Check the visual diff artifacts from the GitHub Actions workflow
  2. Check for Non-Determinism: Ensure your test setup is deterministic
  3. Verify References: Make sure reference images are up to date with the current visual design
  4. Check Environment: The test environment should match the CI environment as closely as possible

Best Practices

  • Keep visual tests focused on specific components or screens
  • Use deterministic values for positions and sizes
  • Avoid animation-dependent tests that might be flaky
  • Update reference images when intentional design changes are made

Overview

Visual testing in Rummage:

  • Verifies UI component rendering
  • Catches visual regressions
  • Ensures consistent appearance
  • Validates UI interactions visually

Testing Approach

Visual testing uses image comparison and automated validation:

  1. Reference Images: Maintain a set of approved reference images
  2. Render Comparison: Generate new renders and compare against references
  3. Pixel Tolerance: Allow small differences to accommodate rendering variations
  4. Visual Regression Detection: Identify unintended visual changes

Example: Card Rendering Test

#![allow(unused)]
fn main() {
#[test]
fn test_card_rendering() {
    // Setup test environment with rendering support
    let mut app = App::new();
    app.add_plugins(RenderTestPlugins);
    
    // Create test card
    let card = setup_test_card(&mut app, "Lightning Bolt");
    
    // Render card to texture
    let texture = render_entity_to_texture(&mut app, card);
    
    // Compare with reference image
    let reference = load_reference_image("cards/lightning_bolt.png");
    
    // Calculate similarity (allowing for minor variations)
    let comparison = compare_images(texture, reference);
    
    // Verify similarity exceeds threshold
    assert!(comparison.similarity > 0.99, 
           "Card should render correctly with 99% similarity to reference");
    
    // If failed, save diff image for review
    if comparison.similarity <= 0.99 {
        save_diff_image("failed_tests/lightning_bolt_diff.png", comparison.diff_image);
    }
}
}

UI Component Testing

Test individual UI components for correct rendering:

#![allow(unused)]
fn main() {
#[test]
fn test_mana_symbol_rendering() {
    // Setup test environment
    let mut app = App::new();
    app.add_plugins(RenderTestPlugins);
    
    // Test all mana symbol types
    let symbol_types = vec!["W", "U", "B", "R", "G", "C", "1", "X"];
    
    for symbol in symbol_types {
        // Create mana symbol entity
        let entity = app.world.spawn((
            ManaSymbol { symbol: symbol.to_string() },
            Transform::default(),
            Visibility::default(),
        )).id();
        
        // Render symbol
        let texture = render_entity_to_texture(&mut app, entity);
        
        // Compare with reference
        let reference = load_reference_image(&format!("mana_symbols/{}.png", symbol));
        let comparison = compare_images(texture, reference);
        
        // Verify rendering
        assert!(comparison.similarity > 0.99, 
               "Mana symbol {} should render correctly", symbol);
    }
}
}

Layout Testing

Test that UI layouts render correctly at different resolutions:

#![allow(unused)]
fn main() {
#[test]
fn test_battlefield_layout() {
    // Setup test environment
    let mut app = App::new();
    app.add_plugins(RenderTestPlugins);
    
    // Test different screen resolutions
    let resolutions = vec![
        (1280, 720),   // HD
        (1920, 1080),  // Full HD
        (2560, 1440),  // QHD
        (3840, 2160),  // 4K
    ];
    
    for (width, height) in resolutions {
        // Set resolution
        app.world.resource_mut::<RenderSettings>().resolution = (width, height);
        
        // Setup a basic battlefield with some cards
        setup_test_battlefield(&mut app, 5);  // 5 cards on battlefield
        
        // Render full battlefield
        let texture = render_screen_to_texture(&mut app);
        
        // Compare with reference for this resolution
        let reference = load_reference_image(&format!("layouts/battlefield_{}x{}.png", width, height));
        let comparison = compare_images(texture, reference);
        
        // Verify layout is correct
        assert!(comparison.similarity > 0.98, 
               "Battlefield layout should render correctly at {}x{}", width, height);
    }
}
}

Animation Testing

Test that animations render correctly:

#![allow(unused)]
fn main() {
#[test]
fn test_card_draw_animation() {
    // Setup test environment
    let mut app = App::new();
    app.add_plugins(RenderTestPlugins);
    
    // Setup player with library and hand
    let player = setup_test_player(&mut app);
    
    // Set up animation capture
    let frames = capture_animation_frames(&mut app, 30, || {
        // Trigger card draw animation
        app.world.send_event(DrawCardEvent { player });
        app.update();
    });
    
    // Check key frames against references
    let key_frame_indices = vec![0, 10, 20, 29];  // Start, middle, end frames
    
    for idx in key_frame_indices {
        let frame = &frames[idx];
        let reference = load_reference_image(&format!("animations/draw_card_frame_{}.png", idx));
        let comparison = compare_images(frame, reference);
        
        assert!(comparison.similarity > 0.97, 
               "Animation frame {} should match reference", idx);
    }
}
}

Accessibility Visual Testing

Test accessibility features visually:

#![allow(unused)]
fn main() {
#[test]
fn test_high_contrast_mode() {
    // Setup test environment
    let mut app = App::new();
    app.add_plugins(RenderTestPlugins);
    
    // Enable high contrast mode
    app.world.resource_mut::<AccessibilitySettings>().high_contrast_mode = true;
    
    // Render battlefield with cards
    setup_test_battlefield(&mut app, 3);
    let texture = render_screen_to_texture(&mut app);
    
    // Compare with high contrast reference
    let reference = load_reference_image("accessibility/high_contrast_battlefield.png");
    let comparison = compare_images(texture, reference);
    
    assert!(comparison.similarity > 0.98, 
           "High contrast mode should render correctly");
    
    // Verify contrast ratios meet WCAG guidelines
    let contrast_analysis = analyze_contrast_ratios(texture);
    assert!(contrast_analysis.min_ratio >= 4.5, 
           "Minimum contrast ratio should meet WCAG AA standard");
}
}

Testing CI Pipeline Integration

Visual tests can be integrated into CI/CD pipelines:

#![allow(unused)]
fn main() {
// This code would be in your CI setup, not an actual test
fn setup_visual_testing_ci() {
    // Run all visual tests
    let test_results = run_visual_tests();
    
    // Process results
    if !test_results.all_passed {
        // Generate report with diffs
        let report = generate_visual_diff_report(test_results);
        
        // Upload diffs as artifacts
        upload_artifacts(report.diff_images);
        
        // Fail the build
        std::process::exit(1);
    }
}
}

Best Practices

For effective visual testing in Rummage:

  1. Maintain Reference Images: Keep a versioned set of approved reference images
  2. Use Appropriate Tolerance: Allow for minor rendering differences across platforms
  3. Test Multiple Resolutions: Verify UI works across different screen sizes
  4. Automate Visual Testing: Integrate visual tests into CI/CD pipelines
  5. Test Accessibility Modes: Verify high-contrast and other accessibility features
  6. Generate Visual Reports: Create visual reports for failed tests
  7. Test With Different Themes: Verify rendering in all visual themes

For more information on testing in Rummage, see:

Visual Differential Testing

Visual differential testing is a technique to automatically detect visual changes between versions of the codebase. Rummage implements a robust visual testing system that integrates with the save/load/replay system to provide powerful testing capabilities.

Core Features

  • Save Game Snapshots: Capture the visual state of any saved game
  • Replay Point Captures: Take snapshots at specific points in game replays
  • Visual Comparison: Compare images against reference images
  • Difference Visualization: Generate visual difference maps
  • CI Integration: Run visual tests in continuous integration pipelines

Basic Usage

Manually Capturing a Screenshot

#![allow(unused)]
fn main() {
use rummage::tests::visual_testing::capture::{request_screenshot, take_screenshot};

// Take a screenshot of the whole screen
let image = take_screenshot();

// Request a screenshot of a specific entity
world.send_event(request_screenshot(entity, "my_screenshot.png"));
}

Working with Saved Games

#![allow(unused)]
fn main() {
use rummage::tests::visual_testing::capture::capture_saved_game_snapshot;

// Capture a screenshot of the current state of a saved game
let image = capture_saved_game_snapshot(world, "my_save", None, None);

// Capture a screenshot of a specific turn
let image = capture_saved_game_snapshot(world, "my_save", Some(3), None);

// Capture a screenshot at a specific replay step
let image = capture_saved_game_snapshot(world, "my_save", None, Some(5));
}

Comparing with Reference Images

#![allow(unused)]
fn main() {
use rummage::tests::visual_testing::comparison::compare_images;
use rummage::tests::visual_testing::utils::load_reference_image;

// Load a reference image
let reference = load_reference_image("my_reference.png").unwrap();

// Compare with a captured image
let result = compare_images(&reference, &captured, 0.1);

// Check if there are significant differences
if result.has_significant_differences() {
    // Save the difference visualization
    result.save_difference("difference.png");
    
    // Take action based on the difference
    panic!("Visual difference detected!");
}
}

Automatic Testing

The visual testing system can automatically test saved games:

#![allow(unused)]
fn main() {
use rummage::tests::visual_testing::fixtures::test_all_saved_games;

// Test all saved games against reference images
let results = test_all_saved_games(world);

// Check results
for result in results {
    if !result.success {
        println!("Test failed: {}", result.name);
    }
}
}

Integration with Save/Load System

The visual testing system integrates with the save/load system to automatically capture snapshots when:

  1. A game is saved
  2. A replay is stepped through
  3. A game is loaded from a save file

This happens automatically through the following systems:

  • take_save_game_snapshot: Captures snapshots when games are saved
  • take_replay_snapshot: Captures snapshots during replay steps

CI Testing Pipeline

The visual testing system can be integrated into a CI pipeline:

#![allow(unused)]
fn main() {
use rummage::tests::visual_testing::ci::{setup_ci_visual_test, is_ci_environment};

// If running in CI environment, configure for CI
if is_ci_environment() {
    setup_ci_visual_test(app);
}

// Run tests
let results = run_visual_tests();

// Report results
for result in results {
    if !result.success {
        // Upload difference images to CI artifacts
        upload_artifact(&result.difference_image);
    }
}
}

Implementation Details

Screenshot Capture

The system can capture screenshots using different methods:

  1. Whole Screen: Capture the entire game window
  2. Entity Focus: Focus on a specific entity
  3. Camera View: Capture what a specific camera sees

Image Comparison

Images are compared using one of several methods:

  1. Pixel-by-Pixel: Exact comparison of each pixel
  2. Histogram: Compare color distributions
  3. Feature-Based: Compare structural features
  4. Neural: Use neural networks for perceptual comparison

Testing Configuration

Configure the visual testing system using the VisualTestConfig resource:

#![allow(unused)]
fn main() {
use rummage::tests::visual_testing::config::{VisualTestConfig, ComparisonMethod};

// Configure visual testing
app.insert_resource(VisualTestConfig {
    reference_directory: PathBuf::from("reference_images"),
    difference_directory: PathBuf::from("difference_images"),
    comparison_method: ComparisonMethod::PixelByPixel,
    similarity_threshold: 0.95,
    generate_references: false,
});
}

Save/Load Integration for Visual Differential Testing

The Rummage visual testing system integrates with the save/load/replay systems to enable visual differential testing of specific game states. This allows developers to:

  1. Capture Game State Snapshots: Automatically take screenshots when games are saved
  2. Replay Visual Validation: Capture visuals during replay for regression testing
  3. Time-Travel Debugging: Compare visuals at different points in game history

Using Save Game Snapshots

Snapshots are automatically captured when a game is saved:

#![allow(unused)]
fn main() {
// Save a game, which triggers a snapshot
world.send_event(SaveGameEvent {
    slot_name: "test_save".to_string(),
});
}

Using Replay Snapshots

Snapshots are taken at each step during replay:

#![allow(unused)]
fn main() {
// Step through a replay, capturing visuals at each step
world.send_event(StepReplayEvent);
}

Testing Game State Evolution

You can use this system to test how the game state evolves visually:

#![allow(unused)]
fn main() {
#[test]
fn test_visual_game_progression() {
    // Setup game and save initial state
    setup_game();
    world.send_event(SaveGameEvent { slot_name: "initial".to_string() });
    
    // Make game progress
    play_turn();
    
    // Save and capture the state after progression
    world.send_event(SaveGameEvent { slot_name: "after_turn".to_string() });
    
    // Compare the visuals between states
    assert_visual_difference("initial", "after_turn", 0.2);
}
}

Implementation

The integration between save/load and visual systems is handled by:

  1. The SaveGameSnapshot component attached to cameras during save/load operations
  2. take_save_game_snapshot and take_replay_snapshot systems that respond to game events
  3. The visual testing framework that can compare snapshot images

This integration enables automated visual regression testing against saved game states, providing a powerful tool for validating game behavior.

Test Fixtures

Snapshot Testing

This document provides an overview of snapshot testing in the Rummage project. For detailed implementation of the snapshot system itself, please see Snapshot System Testing.

Overview

Snapshot testing is a critical component of Rummage's testing strategy, allowing us to capture, store, and compare game states at different points in time. This approach is especially valuable for testing a complex rules engine like Magic: The Gathering, where many interactions can affect the game state in subtle ways.

Benefits of Snapshot Testing

  • Deterministic Testing: Ensures game logic produces consistent, reproducible results
  • Regression Prevention: Quickly identifies when changes affect existing functionality
  • Complex State Verification: Makes it easier to verify complex game states
  • Interaction Testing: Facilitates testing of multi-step card interactions

Snapshot Testing in MTG

In the context of Magic: The Gathering, snapshot testing helps verify:

  1. Rule Correctness: Ensures rules are applied correctly
  2. Card Interactions: Validates complex interactions between cards
  3. Format-Specific Rules: Tests special rules for formats like Commander

Integration with Other Testing Types

Snapshot testing complements other testing approaches:

  • Unit Tests: Verify individual components in isolation
  • Integration Tests: Test how components work together
  • End-to-End Tests: Test the entire game flow

Using Snapshots in Tests

#![allow(unused)]
fn main() {
#[test]
fn test_lightning_bolt_damage() {
    // Set up the game state
    let mut game = setup_test_game();
    let player = game.add_player(20); // Player with 20 life
    
    // Cast Lightning Bolt targeting the player
    let lightning_bolt = game.create_card("Lightning Bolt");
    game.cast(lightning_bolt, Some(player));
    
    // Take a snapshot before resolution
    let pre_resolution = game.create_snapshot();
    
    // Resolve the spell
    game.resolve_top_of_stack();
    
    // Take a snapshot after resolution
    let post_resolution = game.create_snapshot();
    
    // Verify the player's life total decreased by 3
    assert_eq!(pre_resolution.get_player_life(player), 20);
    assert_eq!(post_resolution.get_player_life(player), 17);
    
    // Save the snapshots for future regression tests
    game.save_snapshot("lightning_bolt_pre", pre_resolution);
    game.save_snapshot("lightning_bolt_post", post_resolution);
}
}

Best Practices

  1. Targeted Snapshots: Capture only the relevant parts of game state
  2. Clear Naming: Use descriptive names for snapshots
  3. Minimal Setup: Keep test setup as simple as possible
  4. Deterministic Inputs: Ensure tests have consistent inputs (e.g., fix RNG seeds)
  5. Review Changes: Carefully review snapshot changes in pull requests

CI/CD Pipeline

This document describes the continuous integration and continuous deployment pipeline for the Rummage project.

Overview

Rummage employs a comprehensive CI/CD pipeline to ensure code quality, prevent regressions, and automate the build and deployment process. The pipeline is designed to catch issues early and provide rapid feedback to developers.

Pipeline Structure

Our CI/CD pipeline consists of the following stages:

  1. Code Validation

    • Linting and formatting checks
    • Static analysis
    • Dependency vulnerability scanning
  2. Unit Testing

    • Fast unit tests run on every PR
    • Component and system validation
    • Rule implementation verification
  3. Integration Testing

    • System interaction tests
    • Game flow validation
    • ECS pattern verification
  4. End-to-End Testing

    • Complete game scenario tests
    • Cross-system integration tests
    • Performance benchmarks
  5. Build and Packaging

    • Multi-platform builds
    • Asset bundling
    • Documentation generation
  6. Deployment

    • Development environment deployment
    • Release candidate publishing
    • Production deployment

GitHub Actions Workflow

The pipeline is implemented using GitHub Actions with the following key workflows:

Pull Request Checks

Triggered on every PR to the main branch:

name: All Tests

on:
  push:
    branches: [ main ]
  pull_request:
    branches: [ main ]

jobs:
  build-and-test:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout repository
        uses: actions/checkout@v4

      - name: Setup Rust
        uses: dtolnay/rust-toolchain@stable
        with:
          components: clippy, rustfmt

      - name: Build and test
        run: |
          cargo fmt --all -- --check
          cargo clippy --all-targets --all-features -- -D warnings
          cargo build --all-features
          cargo test --all-features -- --nocapture

This workflow performs:

  • Code formatting check
  • Static analysis with Clippy
  • Full build with all features
  • Comprehensive test suite execution

Visual Testing

We have a dedicated workflow for visual regression testing:

name: Visual Testing

on:
  push:
    branches: [ main ]
  pull_request:
    branches: [ main ]

jobs:
  visual-tests:
    runs-on: ubuntu-latest

    steps:
      # Setup steps...
      
      - name: Run visual tests with Xvfb
        run: |
          xvfb-run --auto-servernum --server-args="-screen 0 1280x720x24" \
          cargo nextest run --package rummage --lib "tests::visual_testing::" -- \
          --test-threads=1 \
          --no-capture

This workflow:

  • Runs in a headless environment using Xvfb
  • Captures screenshots of game states
  • Compares against reference images
  • Uploads difference artifacts for visual inspection

Documentation Deployment

Automatically builds and deploys documentation:

name: Documentation Deployment

on:
  push:
    branches: [ main ]

jobs:
  build-and-deploy:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout repository
        uses: actions/checkout@v4

      - name: Build documentation
        run: cargo doc --no-deps --document-private-items

      - name: Deploy documentation
        uses: actions/upload-artifact@v3
        with:
          name: rummage-docs
          path: target/doc

Local Development Integration

The CI/CD pipeline is also integrated with local development through pre-commit hooks that run a subset of the pipeline checks locally:

#!/bin/sh
# Pre-commit hook for Rummage development
# Install with: cp .github/hooks/pre-commit .git/hooks/ && chmod +x .git/hooks/pre-commit

echo "Running pre-commit checks..."

# Format code
cargo fmt -- --check
if [ $? -ne 0 ]; then
  echo "Error: Code formatting issues detected"
  exit 1
fi

# Run clippy
cargo clippy -- -D warnings
if [ $? -ne 0 ]; then
  echo "Error: Clippy warnings detected"
  exit 1
fi

# Run fast tests
cargo test --lib
if [ $? -ne 0 ]; then
  echo "Error: Unit tests failed"
  exit 1
fi

echo "All pre-commit checks passed!"
exit 0

Test Coverage Reporting

The pipeline includes test coverage reporting to track which parts of the codebase are well-tested:

  1. Coverage Generation: Using tools like tarpaulin to generate coverage reports
  2. Coverage Visualization: Uploading coverage reports to services like Codecov
  3. Minimum Coverage Requirements: Enforcing minimum coverage thresholds

Performance Regression Testing

To catch performance regressions early:

  1. Benchmark Tracking: Storing benchmark results across commits
  2. Performance Alerts: Notifying developers of significant performance changes
  3. Resource Profiling: Monitoring memory usage and CPU utilization

Development Integration

This document describes how testing is integrated with the development workflow in the Rummage project.

Overview

Testing in Rummage is not a separate phase but an integral part of the development process. This integration ensures that code quality is maintained from the earliest stages of development, reducing bugs, preventing regressions, and improving overall code quality.

Test-Driven Development

Rummage follows a test-driven development (TDD) approach for implementing MTG rules and game mechanics:

  1. Write Tests First: Before implementing a feature, write tests that define the expected behavior
  2. Run Tests (Fail): Run the tests to confirm they fail as expected
  3. Implement Feature: Write the minimal code needed to pass the tests
  4. Run Tests (Pass): Verify the tests now pass
  5. Refactor: Clean up the code while maintaining passing tests

Example TDD Workflow for MTG Rules

When implementing a new MTG rule, such as the "Legend Rule":

#![allow(unused)]
fn main() {
// 1. First, write the test
#[test]
fn test_legend_rule() {
    // Setup test environment
    let mut app = App::new();
    app.add_plugins(TestingPlugins);
    
    // Create a player
    let player = app.world.spawn(PlayerMarker).id();
    let battlefield = app.world.spawn(BattlefieldMarker).id();
    
    // Create first legendary creature
    let legend1 = app.world.spawn((
        CardMarker,
        Creature { power: 3, toughness: 3 },
        Legendary,
        Name("Tarmogoyf".to_string()),
        InZone { zone: battlefield },
        Controller { player },
    )).id();
    
    // Create second legendary creature with same name
    let legend2 = app.world.spawn((
        CardMarker,
        Creature { power: 3, toughness: 3 },
        Legendary,
        Name("Tarmogoyf".to_string()),
        InZone { zone: battlefield },
        Controller { player },
    )).id();
    
    // Apply state-based actions
    app.world.send_event(CheckStateBasedActions);
    app.update();
    
    // Verify only one legendary creature remains on battlefield
    let legend_count = app.world.query_filtered::<(), (With<Legendary>, With<CardMarker>)>()
                       .iter(&app.world)
                       .count();
    
    assert_eq!(legend_count, 1, "Only one legendary creature should remain after state-based actions");
}

// 2. Implement the rule
fn apply_legend_rule(
    mut commands: Commands,
    players: Query<(Entity, &PlayerZones)>,
    legends: Query<(Entity, &Controller, &Name), (With<Legendary>, With<CardMarker>)>,
) {
    // Group legends by controller and name
    let mut legend_groups = HashMap::new();
    
    for (legend_entity, controller, name) in legends.iter() {
        legend_groups
            .entry((controller.player, name.0.clone()))
            .or_insert_with(Vec::new)
            .push(legend_entity);
    }
    
    // For each group with more than one legend, keep only the newest
    for (_, legends) in legend_groups.iter() {
        if legends.len() <= 1 {
            continue;
        }
        
        // Keep the first one, sacrifice the rest
        for &legend_entity in legends.iter().skip(1) {
            // Move to graveyard
            // ... implementation details ...
        }
    }
}
}

Continuous Integration Hooks

Development is integrated with the CI/CD pipeline through:

  1. Pre-commit Hooks: Automatically run tests before allowing commits
  2. PR Validation: Enforce passing tests and code standards on PR submission
  3. Integration Gates: Prevent merges that would break existing functionality

See the CI/CD Pipeline documentation for details on these integration points.

Development Environments

Test integration in different development contexts:

Local Development

For local development:

  1. Watch Mode: Tests run automatically when files change

    cargo watch -x "test --lib"
    
  2. Test Filters: Run specific tests during focused development

    cargo test combat -- --nocapture
    
  3. Debug Tests: Run tests with debugging enabled

    rust-lldb target/debug/deps/rummage-1234abcd
    

IDE Integration

Integration with common development environments:

  1. VS Code:

    • Run/debug tests from within the editor
    • Visualize test coverage
    • Code lens for test navigation
  2. IntelliJ/CLion:

    • Run tests from gutter icons
    • Debug test failures
    • View test history

Test Fixtures and Helpers

To streamline the development process, Rummage provides:

  1. Test Fixtures: Common test setups for frequently tested scenarios

    #![allow(unused)]
    fn main() {
    // Use a fixture for standard game setup
    let (mut app, player1, player2) = setup_two_player_game();
    }
  2. Test Utilities: Helper functions for common test operations

    #![allow(unused)]
    fn main() {
    // Utility to simplify card creation
    let lightning_bolt = create_test_card(&mut app, "Lightning Bolt");
    }
  3. Mock Systems: Simplified systems for testing in isolation

    #![allow(unused)]
    fn main() {
    // Replace network systems with mock implementation
    app.add_plugin(MockNetworkPlugin);
    }

Development Tools

Tools that integrate testing into development:

  1. Snapshot Review Tool: Visual interface for reviewing snapshot tests
  2. Coverage Reports: Interactive coverage visualization during development
  3. Performance Monitors: Real-time performance metrics during testing

Best Practices

Guidelines for integrating testing with development:

  1. Write Tests Alongside Code: Tests should be in the same PR as implementation
  2. Maintain Test Coverage: Don't let coverage drop as code grows
  3. Test First for Bug Fixes: Always reproduce bugs with tests before fixing
  4. Run Full Suite Regularly: Don't rely only on focused tests
  5. Document Test Limitations: Make clear what aspects aren't covered by tests

Development Guide

This section provides comprehensive information for developers who want to contribute to or work with the Rummage MTG Commander game engine.

Table of Contents

  1. Introduction
  2. Key Development Areas
  3. Development Environment
  4. Working with Bevy
  5. Integration with Testing

Introduction

The Rummage development guide is designed to help developers understand the architecture, code style, and development practices used in the project. Whether you're a new contributor or an experienced developer, this guide will help you navigate the codebase and make effective contributions.

Rummage follows a test-driven development approach, which means testing is an integral part of the development process. Understanding how development and testing interact will help you create robust, maintainable code that correctly implements the complex MTG rule system.

Key Development Areas

The development documentation is organized into these key areas:

  1. Getting Started

    • Setting up your development environment
    • Building and running the project
    • First steps for new contributors
  2. Architecture Overview

    • High-level system architecture
    • Component relationships
    • Design patterns used
  3. Code Style

    • Coding conventions
    • Documentation standards
    • Best practices
  4. Working with Bevy

  5. Core Systems

    • Snapshot System - Game state serialization and replay functionality
    • Testing - How core systems integrate with testing

Development Environment

To work with Rummage, we recommend the following tools and configurations:

Required Tools

  • Rust (latest stable version)
  • Cargo (comes with Rust)
  • Git
  • A compatible IDE (Visual Studio Code with rust-analyzer recommended)

For Visual Studio Code:

  • rust-analyzer: For Rust language support
  • CodeLLDB: For debugging Rust applications
  • Better TOML: For editing TOML configuration files

Building the Project

Basic build commands:

# Build in debug mode
cargo build

# Build in release mode
cargo build --release

# Run the application
cargo run

# Run tests
cargo test

Working with Bevy

Rummage is built on the Bevy game engine, which provides a data-driven, entity-component-system (ECS) architecture. The Working with Bevy section provides detailed guidance on:

  • Understanding ECS: How Rummage organizes game elements into entities, components, and systems
  • Plugin Development: Creating and working with Bevy plugins
  • Rendering Systems: Implementing visual elements and UI components
  • Bevy 0.15.x Specifics: Working with the latest Bevy APIs

Bevy 0.15.x introduces some important changes, including deprecated UI components like Text2dBundle, SpriteBundle, and NodeBundle which are replaced by Text2d, Sprite, and Node respectively. Our documentation provides guidance on using these newer APIs correctly.

Integration with Testing

Testing is a foundational aspect of Rummage development, ensuring that our implementation correctly follows MTG rules and maintains compatibility across system changes. Our Testing Overview provides comprehensive information on our testing approach.

Test-Driven Development Workflow

When developing new features for Rummage, we follow this test-driven workflow:

  1. Document the Feature: Define requirements and behavior in the documentation
  2. Write Tests First: Create tests that verify the expected behavior
  3. Implement the Feature: Write code that passes the tests
  4. Refactor: Improve the implementation while maintaining test coverage
  5. Integration Testing: Ensure the feature works correctly with other systems

Testing Infrastructure in Development

Our development process is tightly integrated with testing:

  • ECS System Testing: Use ParamSet and other techniques described in the ECS Guide to avoid runtime panics
  • Snapshot Testing: Leverage the Snapshot System for deterministic state verification
  • Visual Testing: For UI components, use our visual differential testing tools

MTG Rule Verification

When implementing MTG rules, refer to both:

By integrating testing throughout the development process, we ensure that Rummage maintains a high level of quality and accurately implements the complex MTG rule system.

Contributing

We welcome contributions to the Rummage project! Please see our contribution guidelines for information on how to submit changes, report issues, and suggest improvements. For specific guidance on our git workflow and commit message format, refer to our Git Workflow Guidelines.

Next Steps

To start developing with Rummage, we recommend:

  1. Read the Getting Started guide
  2. Review the Architecture Overview
  3. Familiarize yourself with Bevy ECS concepts
  4. Review the Testing Overview to understand our testing approach
  5. Check out the API Reference for detailed information on specific components and systems

For questions or assistance, please reach out to the development team through the project's GitHub repository.

Getting Started

Architecture Overview

Code Style

Working with Bevy

This section provides detailed guidance on working with the Bevy game engine in the Rummage project. Bevy is a data-driven game engine built in Rust that uses an Entity Component System (ECS) architecture, which is particularly well-suited for building complex game systems like those required for a Magic: The Gathering implementation.

Table of Contents

  1. Introduction
  2. Key Bevy Concepts
  3. Rummage Bevy Patterns
  4. Bevy Version Considerations
  5. Detailed Guides

Introduction

Rummage uses Bevy 0.15.x as its foundation, leveraging Bevy's modular design and performant ECS to create a robust MTG Commander game engine. This section explains how we use Bevy, our architecture patterns, and best practices for working with Bevy components, systems, and resources.

Key Bevy Concepts

Before diving into Rummage-specific patterns, it's important to understand these core Bevy concepts:

  • Entity: A unique ID that can have components attached to it
  • Component: Data attached to entities (e.g., Card, Player, Battlefield)
  • System: Logic that operates on components (e.g., drawing cards, resolving abilities)
  • Resource: Global data not tied to any specific entity (e.g., GameState, RngResource)
  • Plugin: A collection of systems, components, and resources that can be added to the app
  • Query: A way to access entities and their components in systems
  • Events: Message-passing mechanism between systems

Rummage Bevy Patterns

Rummage follows these patterns for Bevy implementation:

  1. Domain-Specific Plugins: Each game domain (cards, player, zones, etc.) has its own plugin
  2. Component-Heavy Design: Game state is represented primarily through components
  3. Event-Driven Interactions: Game actions are often communicated via events
  4. State-Based Architecture: Game flow is controlled through Bevy's state system
  5. Clean Resource Management: Resources are used judiciously for truly global state

Bevy Version Considerations

Rummage uses Bevy 0.15.x, which introduces some important changes from earlier versions:

  • Deprecated UI Components: Text2dBundle, SpriteBundle, and NodeBundle are deprecated in favor of Text2d, Sprite, and Node respectively
  • App Initialization: Modified approach to app configuration and plugin registration
  • System Sets: Updated system ordering mechanism
  • Asset Loading: Enhanced asset loading system
  • Time Management: Improved time and timer APIs

Always check for and fix any compiler warnings that might indicate usage of deprecated APIs.

Detailed Guides

For more detailed information on working with Bevy in the Rummage project, explore these specific topics:

  1. Entity Component System - Detailed guide on how Rummage uses ECS architecture
  2. Plugin Architecture - How plugins are organized and composed in Rummage
  3. Rendering - Card rendering, UI, and visual effects implementation
  4. Camera Management - Camera setup, management, and common issues
  5. Random Number Generation - Using bevy_rand for entity-attached RNGs and networked state sharing

Next: Entity Component System

Entity Component System

This guide explains how the Entity Component System (ECS) architecture is implemented in Rummage using Bevy, and provides practical advice for working with ECS patterns.

Table of Contents

  1. Introduction to ECS
  2. ECS in Bevy
  3. Game Entities in Rummage
  4. Component Design
  5. System Design
  6. Queries and Filters
  7. ECS Best Practices
  8. Common Pitfalls
  9. Safely Using Parameter Sets

Introduction to ECS

Entity Component System (ECS) is an architectural pattern that separates identity (entities), data (components), and logic (systems). This separation offers several advantages:

  • Performance: Enables cache-friendly memory layouts and parallel execution
  • Flexibility: Allows for dynamic composition of game objects
  • Modularity: Decouples data from behavior for better code organization
  • Extensibility: Makes it easier to add new features without modifying existing code

ECS in Bevy

Bevy's ECS implementation includes these core elements:

Entities

Entities in Bevy are simply unique identifiers that components can be attached to. They're created using the Commands API:

#![allow(unused)]
fn main() {
// Creating a new entity
commands.spawn_empty();

// Creating an entity with components
commands.spawn((
    Card { id: "fireball", cost: "{X}{R}" },
    CardName("Fireball".to_string()),
    SpellType::Instant,
));
}

Components

Components are simple data structures that can be attached to entities:

#![allow(unused)]
fn main() {
// Component definition
#[derive(Component)]
struct Health {
    current: i32,
    maximum: i32,
}

// Component with derive macros for common functionality
#[derive(Component, Debug, Clone, PartialEq)]
struct ManaCost {
    blue: u8,
    black: u8,
    red: u8,
    green: u8,
    white: u8,
    colorless: u8,
}
}

Systems

Systems are functions that operate on components:

#![allow(unused)]
fn main() {
// Simple system that operates on Health components
fn heal_system(mut query: Query<&mut Health>) {
    for mut health in &mut query {
        health.current = health.current.min(health.maximum);
    }
}

// System that uses multiple component types
fn damage_system(
    mut commands: Commands,
    mut query: Query<(Entity, &mut Health, &DamageReceiver)>,
    time: Res<Time>,
) {
    for (entity, mut health, damage) in &mut query {
        health.current -= damage.amount;
        
        if health.current <= 0 {
            commands.entity(entity).insert(DeathMarker);
        }
    }
}
}

Resources

Resources are global singleton data structures:

#![allow(unused)]
fn main() {
// Resource definition
#[derive(Resource)]
struct GameState {
    turn: usize,
    phase: Phase,
    active_player: usize,
}

// Accessing resources in systems
fn turn_system(mut game_state: ResMut<GameState>) {
    game_state.turn += 1;
    // ...
}
}

Game Entities in Rummage

Rummage represents game concepts as entities with appropriate components:

Cards

Cards are entities with components like:

  • Card - Core card data
  • CardName - The card's name
  • ManaCost - Mana cost information
  • CardType - Card type information
  • Position components for visual placement

Players

Players are entities with components like:

  • Player - Player information
  • Life - Current life total
  • Hand - Reference to hand entity
  • Commander - Reference to commander entity
  • Library - Reference to library entity

Zones

Game zones (like battlefield, graveyard) are entities with components like:

  • Zone - Zone type and metadata
  • ZoneContents - References to contained entities
  • Visual placement components

Component Design

When designing components for Rummage, follow these guidelines:

Keep Components Focused

Components should represent a single aspect of an entity. For example, separate Health, AttackPower, and BlockStatus rather than a single CombatStats component.

Efficient Component Storage

Consider the memory layout of components:

  • Use primitive types where possible
  • For small fixed-size collections, use arrays instead of Vecs
  • For larger collections, consider using entity references instead of direct data storage

Component Relationships

Use entity references to establish relationships between components:

#![allow(unused)]
fn main() {
#[derive(Component)]
struct Attachments {
    attached_to: Entity,
    attached_cards: Vec<Entity>,
}
}

System Design

Systems should follow these design principles:

Single Responsibility

Each system should have a clear, well-defined responsibility. For example:

  • draw_card_system - Handles drawing cards from library to hand
  • apply_damage_system - Applies damage to creatures and players
  • check_state_based_actions - Checks and applies state-based actions

System Organization

Systems are organized in the codebase by domain:

  • card/systems.rs - Card-related systems
  • combat/systems.rs - Combat-related systems
  • player/systems.rs - Player-related systems

System Scheduling

Bevy 0.15 uses system sets for scheduling. Rummage organizes systems into sets like:

#![allow(unused)]
fn main() {
#[derive(SystemSet, Debug, Hash, PartialEq, Eq, Clone)]
enum CardSystemSet {
    Draw,
    Play,
    Resolve,
}

app
    .configure_sets(
        Update, 
        (CardSystemSet::Draw, CardSystemSet::Play, CardSystemSet::Resolve).chain()
    )
    .add_systems(
        Update,
        (draw_card, mill_cards).in_set(CardSystemSet::Draw)
    );
}

Now that we understand how systems are organized and scheduled, let's explore how systems access and manipulate entity data through queries.

Queries and Filters

Queries are the primary way to access entity data in systems. Here are some common query patterns used in Rummage:

Basic Queries

#![allow(unused)]
fn main() {
// Query for a single component type
fn system(query: Query<&Card>) {
    for card in &query {
        // Use card data
    }
}

// Query for multiple component types
fn system(query: Query<(&Card, &CardName, &ManaCost)>) {
    for (card, name, cost) in &query {
        // Use all components
    }
}

// Query with mutable access
fn system(mut query: Query<&mut Health>) {
    for mut health in &mut query {
        health.current += 1;
    }
}
}

Filtering Queries

#![allow(unused)]
fn main() {
// Filter to only entities with specific components
fn system(query: Query<&Card, With<Creature>>) {
    // Only processes cards that are creatures
}

// Filter to exclude entities with specific components
fn system(query: Query<&Card, Without<Tapped>>) {
    // Only processes cards that aren't tapped
}

// Combining filters
fn system(query: Query<&Card, (With<Creature>, Without<Tapped>)>) {
    // Only processes creature cards that aren't tapped
}
}

Entity Access

#![allow(unused)]
fn main() {
// Getting the entity ID along with components
fn system(query: Query<(Entity, &Card)>) {
    for (entity, card) in &query {
        // Use entity ID and card
    }
}

// Looking up a specific entity
fn system(
    commands: Commands, 
    query: Query<&Card>,
    player_query: Query<&Player>
) {
    if let Ok(player) = player_query.get(player_entity) {
        // Use player data
    }
}
}

ECS Best Practices

Performance Considerations

  1. Batch operations: Use commands.spawn_batch() for creating multiple similar entities
  2. Query optimization: Be specific about which components you query
  3. Change detection: Use Changed to only run logic when components change
  4. Parallelism awareness: Design systems to avoid conflicts that would prevent parallelism

Maintainable Code

  1. Document component purposes: Each component should have clear documentation
  2. System naming: Use clear, descriptive names for systems
  3. Consistent patterns: Follow established patterns for similar features
  4. Tests: Write unit tests for systems and component interactions

Common Pitfalls

Multiple Mutable Borrows

Bevy will panic if you try to mutably access the same component multiple times in a system.

Problem:

#![allow(unused)]
fn main() {
fn problematic_system(mut query: Query<(&mut Health, &mut Damage)>) {
    // This could panic if an entity has both Health and Damage components
}
}

Solution:

#![allow(unused)]
fn main() {
fn fixed_system(
    mut health_query: Query<&mut Health>,
    damage_query: Query<(Entity, &Damage)>,
) {
    for (entity, damage) in &damage_query {
        if let Ok(mut health) = health_query.get_mut(entity) {
            health.current -= damage.amount;
        }
    }
}
}

Query For Single Entity

When you expect a single entity to match a query but get multiple, Bevy will panic with a "MultipleEntities" error.

Problem:

#![allow(unused)]
fn main() {
fn get_camera_system(camera_query: Query<(&Camera, &GlobalTransform)>) {
    // This will panic if there are multiple camera entities
    let (camera, transform) = camera_query.single();
}
}

Solution:

#![allow(unused)]
fn main() {
fn get_camera_system(camera_query: Query<(&Camera, &GlobalTransform), With<MainCamera>>) {
    // Add a marker component to your main camera
    if let Ok((camera, transform)) = camera_query.get_single() {
        // Now we only get the one with the MainCamera marker
    }
}
}

Event Overflow

Event readers that don't consume all events can cause memory growth.

Problem:

#![allow(unused)]
fn main() {
fn card_draw_system(mut event_reader: EventReader<DrawCardEvent>) {
    // Only process the first event each frame
    if let Some(event) = event_reader.iter().next() {
        // Process one event, leaving others unconsumed
    }
}
}

Solution:

#![allow(unused)]
fn main() {
fn card_draw_system(mut event_reader: EventReader<DrawCardEvent>) {
    // Process all events
    for event in event_reader.iter() {
        // Process each event
    }
}
}

The pitfalls discussed above highlight some of the common issues you might encounter when working with Bevy's ECS. In the next section, we'll explore more advanced techniques to prevent these issues from occurring in the first place.

Safely Using Parameter Sets

Bevy's ECS enforces strict borrowing rules to maintain memory safety and enable parallelism. A common cause of runtime panics is query parameter conflicts, especially when working with complex systems. This section covers techniques to write robust systems that avoid these issues.

Understanding Parameter Sets

Parameter sets provide a way to group related parameters and control how they interact with each other. By explicitly defining parameter sets, you can prevent Bevy from attempting to run systems with conflicting queries in parallel, which would cause runtime panics.

Disjoint Queries with Param Sets

The ParamSet type allows you to create multiple queries that would otherwise conflict with each other:

#![allow(unused)]
fn main() {
use bevy::ecs::system::ParamSet;

fn safe_system(
    mut param_set: ParamSet<(
        Query<&mut Transform, With<Player>>,
        Query<&mut Transform, With<Enemy>>
    )>
) {
    // Access the first query (player transforms)
    for mut transform in param_set.p0().iter_mut() {
        // Modify player transforms
    }
    
    // Access the second query (enemy transforms)
    for mut transform in param_set.p1().iter_mut() {
        // Modify enemy transforms
    }
}
}

This approach is safer than trying to use separate queries because ParamSet guarantees that access to each query is sequential rather than simultaneous.

Using Component Access for Safety

For more complex systems, you can use the ComponentAccess trait to explicitly control which components your system accesses:

#![allow(unused)]
fn main() {
#[derive(Default, Resource)]
struct SafeComponentAccess {
    processing_cards: bool,
}

fn card_system(
    mut access: ResMut<SafeComponentAccess>,
    mut query: Query<&mut Card>,
) {
    // Set flag to indicate we're processing cards
    access.processing_cards = true;
    
    for mut card in &mut query {
        // Process cards safely
    }
    
    // Release the lock
    access.processing_cards = false;
}

fn other_card_system(
    access: Res<SafeComponentAccess>,
    mut commands: Commands,
) {
    // Check if another system is processing cards
    if !access.processing_cards {
        // Safe to spawn or modify cards
        commands.spawn(Card::default());
    }
}
}

Avoiding World References

While it's possible to access the entire ECS World in a system, this approach bypasses Bevy's safety mechanisms and should be avoided whenever possible:

Problematic Approach:

#![allow(unused)]
fn main() {
fn unsafe_world_system(world: &mut World) {
    // Direct world access bypasses Bevy's safety checks
    let mut cards = world.query::<&mut Card>();
    
    for mut card in cards.iter_mut(world) {
        // This might conflict with other systems
    }
}
}

Safer Alternative:

#![allow(unused)]
fn main() {
fn safe_system(mut query: Query<&mut Card>) {
    for mut card in &mut query {
        // Bevy will handle safety and scheduling
    }
}
}

Query Lifetimes and Temporary Storage

This example shows how to safely implement card manipulation systems that would otherwise conflict with each other. By either using ParamSet or breaking the operation into separate systems with clear dependencies, we avoid the common causes of ECS panics.

Testing for Query Conflicts

#![allow(unused)]
fn main() {
#[test]
fn verify_system_sets_compatibility() {
    let mut app = App::new();
    
    // Add systems that should be compatible
    app.add_systems(Update, (system_a, system_b));
    
    // Verify no conflicts using Bevy's built-in detection
    app.world_mut().get_archetypes();
}
}

While the techniques above apply broadly to all ECS systems, some specific system types present unique challenges. One particularly complex area in game development is state snapshotting and replay, which we'll explore next.

Working with Snapshot Systems

Game state snapshot systems can be particularly prone to query conflicts since they often need to access a wide range of components. Here are patterns to make snapshot systems more robust:

Isolating Snapshot Systems

Place snapshot-related systems in dedicated sets that run at specific points in the frame:

#![allow(unused)]
fn main() {
#[derive(SystemSet, Debug, Hash, PartialEq, Eq, Clone)]
enum SnapshotSystemSet {
    PrepareSnapshot,
    ProcessEvents,
    ApplySnapshot,
}

app
    .configure_sets(
        Update,
        (
            // Run snapshot systems after regular game systems
            GameSystemSet::All,
            SnapshotSystemSet::PrepareSnapshot,
            SnapshotSystemSet::ProcessEvents,
            SnapshotSystemSet::ApplySnapshot,
        ).chain()
    );
}

Deferred Snapshot Processing

Instead of trying to query and modify components immediately, gather snapshot data and defer processing:

#![allow(unused)]
fn main() {
#[derive(Resource, Default)]
struct PendingSnapshots {
    snapshots: Vec<GameSnapshot>,
}

// First system: collect snapshot data
fn handle_snapshot_events(
    mut event_reader: EventReader<SnapshotEvent>,
    mut pending: ResMut<PendingSnapshots>,
    query: Query<&GameState>,
) {
    for event in event_reader.iter() {
        // Collect necessary data without modifying anything
        let snapshot = create_snapshot(&query, event);
        pending.snapshots.push(snapshot);
    }
}

// Second system: process collected snapshots
fn process_pending_snapshots(
    mut commands: Commands,
    mut pending: ResMut<PendingSnapshots>,
) {
    for snapshot in pending.snapshots.drain(..) {
        // Now apply changes using commands
        apply_snapshot(&mut commands, snapshot);
    }
}
}

Read-Only Snapshots

When possible, make snapshots read-only operations that don't modify components directly:

#![allow(unused)]
fn main() {
fn create_snapshot(
    query: Query<(Entity, &Transform, &Health), With<Snapshotable>>,
) -> GameSnapshot {
    let mut snapshot = GameSnapshot::default();
    
    for (entity, transform, health) in &query {
        snapshot.entities.push(SnapshotEntry {
            entity,
            position: transform.translation,
            health: health.current,
        });
    }
    
    snapshot
}
}

Command-Based Modifications

When applying snapshots, use Commands to defer actual entity modifications:

#![allow(unused)]
fn main() {
fn apply_snapshot_system(
    mut commands: Commands,
    snapshots: Res<SnapshotRepository>,
    entities: Query<Entity, With<Snapshotable>>,
) {
    if let Some(snapshot) = snapshots.get_latest() {
        // First remove outdated entities
        for entity in &entities {
            if !snapshot.contains(entity) {
                commands.entity(entity).despawn_recursive();
            }
        }
        
        // Then apply snapshot data using commands
        for entry in &snapshot.entities {
            commands.spawn((
                Snapshotable,
                Transform::from_translation(entry.position),
                Health { current: entry.health, maximum: entry.max_health },
            ));
        }
    }
}
}

This approach ensures that entity modifications happen at safe times controlled by Bevy's command buffer system.

Debugging Snapshot Systems with Trace Logging

Snapshot systems can be particularly difficult to debug due to their complex interactions with the ECS. Structured logging can help identify where issues occur:

#![allow(unused)]
fn main() {
fn handle_snapshot_events(
    mut event_reader: EventReader<SnapshotEvent>,
    mut pending: ResMut<PendingSnapshots>,
) {
    // Log system entry with count of events
    trace!(system = "handle_snapshot_events", event_count = event_reader.len(), "Entering system");
    
    // Process events
    for event in event_reader.iter() {
        trace!(system = "handle_snapshot_events", event_id = ?event.id, "Processing event");
        
        match process_event(event, &mut pending) {
            Ok(_) => trace!(system = "handle_snapshot_events", event_id = ?event.id, "Successfully processed"),
            Err(e) => error!(system = "handle_snapshot_events", event_id = ?event.id, error = ?e, "Failed to process"),
        }
    }
    
    // Log system exit
    trace!(system = "handle_snapshot_events", "Exiting system");
}
}

When debugging snapshot systems, look for these common patterns in logs:

  1. Systems that enter but never exit (indicating a panic or infinite loop)
  2. Mismatched counts between processed and expected items
  3. Systems that execute in unexpected orders
  4. Repeated errors processing the same entities

For complex debugging, consider a custom snapshot debug viewer:

#![allow(unused)]
fn main() {
#[derive(Resource)]
struct SnapshotDebugger {
    history: Vec<SnapshotDebugEntry>,
    active_systems: HashSet<&'static str>,
}

impl SnapshotDebugger {
    fn system_enter(&mut self, name: &'static str) {
        self.active_systems.insert(name);
        self.history.push(SnapshotDebugEntry {
            timestamp: std::time::Instant::now(),
            event: format!("System entered: {}", name),
        });
    }
    
    fn system_exit(&mut self, name: &'static str) {
        self.active_systems.remove(name);
        self.history.push(SnapshotDebugEntry {
            timestamp: std::time::Instant::now(),
            event: format!("System exited: {}", name),
        });
    }
}

// Add to app startup
app.init_resource::<SnapshotDebugger>();

// Modified system with detailed tracing
fn handle_snapshot_events(
    mut debugger: ResMut<SnapshotDebugger>,
    mut event_reader: EventReader<SnapshotEvent>,
    mut pending: ResMut<PendingSnapshots>,
) {
    let system_name = "handle_snapshot_events";
    debugger.system_enter(system_name);
    
    // System logic here
    
    debugger.system_exit(system_name);
}
}

This approach creates a permanent record of system execution that persists even if the system panics, making it easier to reconstruct what happened.

MTG-Specific Example: Card Manipulation Safety

Now that we've covered the general techniques for safe ECS usage, let's apply these concepts to a concrete example in our Magic: The Gathering implementation. Card manipulation systems are a perfect illustration of where these safety techniques are crucial.

#![allow(unused)]
fn main() {
// Define components for MTG cards
#[derive(Component)]
struct Card {
    id: String,
    power: Option<i32>,
    toughness: Option<i32>,
}

#[derive(Component)]
enum CardZone {
    Battlefield,
    Graveyard,
    Hand,
    Library,
    Exile,
}

// ParamSet approach for a card movement system
fn move_card_system(
    mut param_set: ParamSet<(
        // Queries for different card zones
        Query<(Entity, &Card), With<CardZone>>,
        Query<&mut CardZone>,
    )>,
    commands: Commands,
) {
    // First gather all relevant card entities
    let mut cards_to_move = Vec::new();
    
    // Using the first query to find cards
    for (entity, card) in param_set.p0().iter() {
        if should_move_card(card) {
            cards_to_move.push(entity);
        }
    }
    
    // Then use the second query to update zone components
    for entity in cards_to_move {
        if let Ok(mut zone) = param_set.p1().get_mut(entity) {
            // Update the zone safely
            *zone = CardZone::Graveyard;
        }
    }
}

// Alternative approach using system sets for battlefield organization
#[derive(SystemSet, Debug, Hash, PartialEq, Eq, Clone)]
enum CardSystemSet {
    Preparation,
    ZoneChanges,
    StatUpdates,
    Cleanup,
}

// First system: identify cards to organize
fn identify_battlefield_cards(
    query: Query<Entity, With<CardZone>>,
    mut card_commands: ResMut<CardCommands>,
) {
    card_commands.to_organize.clear();
    
    for entity in &query {
        if should_organize(entity) {
            card_commands.to_organize.push(entity);
        }
    }
}

// Second system: update card positions
fn organize_battlefield_cards(
    mut query: Query<&mut Transform>,
    card_commands: Res<CardCommands>,
) {
    for (index, &entity) in card_commands.to_organize.iter().enumerate() {
        if let Ok(mut transform) = query.get_mut(entity) {
            // Calculate new position
            let position = calculate_card_position(index);
            transform.translation = position;
        }
    }
}

// Register systems in the correct order
app
    .configure_sets(
        Update,
        (
            CardSystemSet::Preparation,
            CardSystemSet::ZoneChanges,
            CardSystemSet::StatUpdates,
            CardSystemSet::Cleanup,
        ).chain()
    )
    .add_systems(
        Update,
        identify_battlefield_cards.in_set(CardSystemSet::Preparation)
    )
    .add_systems(
        Update,
        organize_battlefield_cards.in_set(CardSystemSet::ZoneChanges)
    );
}

This example shows how to safely implement card manipulation systems that would otherwise conflict with each other. By either using ParamSet or breaking the operation into separate systems with clear dependencies, we avoid the common causes of ECS panics.

Conclusion

Bevy's ECS provides a powerful foundation for building complex game systems, but it requires careful attention to system design and query patterns to avoid runtime panics. By following the best practices and safety techniques outlined in this guide, you can build robust, maintainable systems for your Magic: The Gathering implementation.

Remember these key principles:

  • Keep components focused and well-documented
  • Use appropriate query filters to target exactly the entities you need
  • Handle potential conflicts with ParamSet and system ordering
  • Use Commands for deferred modifications when appropriate
  • Add detailed logging for complex system interactions
  • Test your systems thoroughly, including compatibility verification

Next: Plugin Architecture

Plugin Architecture

This guide explains how the plugin architecture is implemented in Rummage using Bevy, and provides guidelines for working with and creating plugins.

Table of Contents

  1. Introduction to Bevy Plugins
  2. Rummage Plugin Structure
  3. Core Plugins
  4. Creating Plugins
  5. Plugin Dependencies
  6. Testing Plugins
  7. Best Practices

Introduction to Bevy Plugins

Bevy's plugin system is a powerful way to organize and modularize game functionality. Plugins can add systems, resources, events, and other game elements to the Bevy App in a self-contained way. This modular approach allows for:

  • Code organization: Grouping related functionality
  • Reusability: Using plugins across different projects
  • Composability: Building complex behavior from simpler plugins
  • Testability: Testing plugins in isolation

Rummage Plugin Structure

The Rummage codebase is organized around domain-specific plugins that encapsulate different aspects of the game engine. Here's the high-level plugin architecture:

src/
├── plugins/
│   ├── mod.rs                 # Exports all plugins
│   └── core.rs                # Core plugin configuration
├── game_engine/               # Game engine plugins
│   ├── mod.rs
│   ├── plugin.rs              # Main game engine plugin
│   └── ...
├── card/                      # Card-related plugins
│   ├── mod.rs
│   ├── plugin.rs              # Card system plugin
│   └── ...
├── player/                    # Player-related plugins
│   ├── mod.rs
│   ├── plugin.rs              # Player system plugin
│   └── ...
└── ...

In Rummage, each major subsystem is implemented as a plugin, which may compose multiple smaller plugins.

Core Plugins

Rummage has several core plugins that provide essential functionality:

GameEnginePlugin

The GameEnginePlugin is responsible for the core game mechanics:

#![allow(unused)]
fn main() {
pub struct GameEnginePlugin;

impl Plugin for GameEnginePlugin {
    fn build(&self, app: &mut App) {
        app
            // Add game engine resources
            .init_resource::<GameState>()
            .init_resource::<TurnState>()
            
            // Register game engine events
            .add_event::<PhaseChangeEvent>()
            .add_event::<TurnChangeEvent>()
            
            // Add game engine systems
            .add_systems(Update, (
                process_game_phase,
                handle_turn_changes,
                check_state_based_actions,
            ));
    }
}
}

CardPlugin

The CardPlugin handles card-related functionality:

#![allow(unused)]
fn main() {
pub struct CardPlugin;

impl Plugin for CardPlugin {
    fn build(&self, app: &mut App) {
        app
            // Add card-related resources
            .init_resource::<CardDatabase>()
            
            // Register card-related events
            .add_event::<CardDrawnEvent>()
            .add_event::<CardPlayedEvent>()
            
            // Add card-related systems
            .add_systems(Update, (
                load_card_database,
                process_card_effects,
            ));
    }
}
}

PlayerPlugin

The PlayerPlugin manages player-related functionality:

#![allow(unused)]
fn main() {
pub struct PlayerPlugin;

impl Plugin for PlayerPlugin {
    fn build(&self, app: &mut App) {
        app
            // Add player-related resources
            .init_resource::<PlayerRegistry>()
            
            // Register player-related events
            .add_event::<PlayerDamageEvent>()
            .add_event::<PlayerLifeChangeEvent>()
            
            // Add player-related systems
            .add_systems(Update, (
                update_player_life,
                check_player_elimination,
            ));
    }
}
}

Creating Plugins

When creating a new plugin for Rummage, follow these steps:

  1. Identify responsibility: Define a clear domain of responsibility for your plugin
  2. Create plugin structure: Create a new module with the plugin definition
  3. Implement resources and components: Define data structures needed
  4. Implement systems: Create systems that operate on your data
  5. Register with app: Implement the Plugin trait to register everything

Here's a template for creating a new plugin:

#![allow(unused)]
fn main() {
use bevy::prelude::*;

// Define your plugin
pub struct MyFeaturePlugin;

// Define plugin-specific resources
#[derive(Resource, Default)]
pub struct MyFeatureResource {
    // Resource data
}

// Define plugin-specific components
#[derive(Component)]
pub struct MyFeatureComponent {
    // Component data
}

// Define plugin-specific events
#[derive(Event)]
pub struct MyFeatureEvent {
    // Event data
}

// Define plugin-specific systems
fn my_feature_system(
    mut commands: Commands,
    query: Query<&MyFeatureComponent>,
    mut resource: ResMut<MyFeatureResource>,
) {
    // System logic
}

// Implement the Plugin trait
impl Plugin for MyFeaturePlugin {
    fn build(&self, app: &mut App) {
        app
            // Initialize resources
            .init_resource::<MyFeatureResource>()
            
            // Register events
            .add_event::<MyFeatureEvent>()
            
            // Add systems
            .add_systems(Update, (
                my_feature_system,
                // Other systems
            ));
    }
}
}

Plugin Dependencies

Plugins often depend on functionality provided by other plugins. In Bevy, plugin dependencies are managed through the order in which plugins are added to the app.

Explicit Ordering

In Rummage, we handle plugin dependencies explicitly in the main app setup:

#![allow(unused)]
fn main() {
// In main.rs or lib.rs
app
    // Core plugins first
    .add_plugins(RummageGameCorePlugins)
    
    // Dependent plugins next
    .add_plugin(CardPlugin)
    .add_plugin(PlayerPlugin)
    .add_plugin(ZonePlugin)
    
    // Higher-level plugins that depend on the above
    .add_plugin(CombatPlugin)
    .add_plugin(EffectsPlugin);
}

Plugin Groups

For related plugins, we use Bevy's plugin groups to organize them:

#![allow(unused)]
fn main() {
pub struct RummageGameCorePlugins;

impl PluginGroup for RummageGameCorePlugins {
    fn build(self) -> PluginGroupBuilder {
        PluginGroupBuilder::start::<Self>()
            .add(GameStatePlugin)
            .add(EventLoggingPlugin)
            .add(AssetLoadingPlugin)
    }
}
}

Testing Plugins

Plugins should be tested in isolation as much as possible. Bevy provides utilities for testing plugins.

Unit Testing Plugins

Here's how to test a single system from a plugin:

#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
    use super::*;
    use bevy::prelude::*;
    
    #[test]
    fn test_my_feature_system() {
        // Set up a minimal App with just what we need
        let mut app = App::new();
        
        // Add resources and register components
        app.init_resource::<MyFeatureResource>();
        
        // Add the system under test
        app.add_systems(Update, my_feature_system);
        
        // Set up test entities
        app.world.spawn(MyFeatureComponent { /* ... */ });
        
        // Run the system
        app.update();
        
        // Assert expected outcomes
        let resource = app.world.resource::<MyFeatureResource>();
        assert_eq!(resource.some_value, expected_value);
    }
}
}

Integration Testing Plugins

For testing a complete plugin:

#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
    use super::*;
    use bevy::prelude::*;
    
    #[test]
    fn test_my_feature_plugin() {
        // Set up a minimal App
        let mut app = App::new();
        
        // Add our plugin
        app.add_plugin(MyFeaturePlugin);
        
        // Set up test entities and initial state
        // ...
        
        // Run systems for a few frames
        app.update();
        app.update();
        
        // Assert expected outcomes
        // ...
    }
}
}

Best Practices

When working with plugins in Rummage, follow these best practices:

Organization

  • One domain per plugin: Each plugin should have a single, well-defined responsibility
  • Hierarchical structure: Compose complex plugins from simpler ones
  • Clear naming: Name plugins descriptively based on functionality

Plugin Design

  • Minimal dependencies: Minimize dependencies between plugins
  • Configuration options: Make plugins configurable through parameters or resources
  • Clean interfaces: Define clear interfaces for inter-plugin communication

Implementation

  • Documentation: Document each plugin's purpose and functionality
  • Resource naming: Use descriptive, domain-specific names for resources
  • Event-based communication: Use events for loose coupling between plugins
  • Testability: Design plugins to be testable in isolation

Example: Well-Designed Plugin

#![allow(unused)]
fn main() {
/// Plugin responsible for handling Magic card drawing mechanics
///
/// This plugin manages:
/// - Drawing cards from library to hand
/// - "Draw X cards" effects
/// - Replacement effects for card drawing
/// - Events related to card drawing
pub struct CardDrawPlugin {
    /// Maximum hand size (default: 7)
    pub max_hand_size: usize,
}

impl Default for CardDrawPlugin {
    fn default() -> Self {
        Self {
            max_hand_size: 7,
        }
    }
}

impl Plugin for CardDrawPlugin {
    fn build(&self, app: &mut App) {
        app
            // Store configuration
            .insert_resource(CardDrawConfig {
                max_hand_size: self.max_hand_size,
            })
            
            // Register events
            .add_event::<CardDrawEvent>()
            .add_event::<DrawReplacementEvent>()
            
            // Add systems with appropriate ordering
            .add_systems(Update, (
                process_draw_effects,
                move_cards_to_hand,
                check_maximum_hand_size,
            ).chain());
    }
}
}

Next: Rendering

Rendering

This guide explains how rendering is implemented in Rummage using Bevy, focusing on card rendering, UI components, and visual effects.

Table of Contents

  1. Introduction to Bevy Rendering
  2. Rendering Architecture in Rummage
  3. Card Rendering
  4. UI Components
  5. Visual Effects
  6. Performance Optimization
  7. Best Practices
  8. Common Issues

Introduction to Bevy Rendering

Bevy provides a powerful, flexible rendering system that uses a render graph to define how rendering occurs. Key components of Bevy's rendering system include:

  • Render pipeline: Configures how meshes, textures, and other visual elements are processed by the GPU
  • Materials: Define surface properties of rendered objects
  • Meshes: 3D geometry data
  • Textures: Image data used for rendering
  • Cameras: Define viewports into the rendered scene
  • Sprites: 2D images rendered in the world
  • UI nodes: User interface elements

In Bevy 0.15.x, some important changes were made to the rendering API:

  • Text2dBundle, SpriteBundle, and NodeBundle are deprecated in favor of Text2d, Sprite, and Node components
  • Enhanced material system
  • Improved shader support
  • Better handling of textures and assets

Rendering Architecture in Rummage

Rummage employs a layered rendering architecture:

  1. Game World Layer: Renders the battlefield, zones, and cards
  2. UI Overlay Layer: Renders UI elements like menus, tooltips, and dialogs
  3. Effect Layer: Renders visual effects and animations

The rendering is managed through several dedicated plugins:

  • RenderPlugin: Core rendering setup
  • CardRenderPlugin: Card-specific rendering
  • UIPlugin: User interface rendering
  • EffectPlugin: Visual effects and animations

Card Rendering

Cards are the central visual element in a Magic: The Gathering game. Rummage's card rendering system handles both full cards and minimized versions.

Card Components

Card rendering uses these key components:

#![allow(unused)]
fn main() {
// Card visual representation
#[derive(Component)]
pub struct CardVisual {
    pub style: CardStyle,
    pub state: CardVisualState,
}

// Card visual state (tapped, highlighted, etc.)
#[derive(Component)]
pub enum CardVisualState {
    Normal,
    Tapped,
    Highlighted,
    Selected,
    Targeted,
}

// Card render options
#[derive(Component)]
pub struct CardRenderOptions {
    pub show_full_art: bool,
    pub zoom_on_hover: bool,
    pub animation_speed: f32,
}
}

Card Rendering System

The main card rendering system:

#![allow(unused)]
fn main() {
// Example system for updating card visuals based on game state
fn update_card_visuals(
    mut commands: Commands,
    mut card_query: Query<(Entity, &Card, &mut Transform, Option<&CardVisual>)>,
    card_state_query: Query<&CardGameState>,
    asset_server: Res<AssetServer>,
) {
    for (entity, card, mut transform, visual) in &mut card_query {
        // Get card state (tapped, etc.)
        let card_state = card_state_query.get(entity).unwrap_or(&CardGameState::Normal);
        
        // If no visual component or state changed, update visual
        if visual.is_none() || visual.unwrap().state != card_state.into() {
            // Load card texture
            let texture = asset_server.load(&format!("cards/{}.png", card.id));
            
            // Update or add visual components
            commands.entity(entity).insert((
                // In Bevy 0.15, we use the Sprite component directly, not SpriteBundle
                Sprite {
                    custom_size: Some(Vec2::new(CARD_WIDTH, CARD_HEIGHT)),
                    ..default()
                },
                // Add texture
                texture,
                // Add visual state
                CardVisual {
                    style: CardStyle::Standard,
                    state: card_state.into(),
                },
            ));
            
            // Update transform based on state (e.g., rotate if tapped)
            if matches!(card_state, CardGameState::Tapped) {
                transform.rotation = Quat::from_rotation_z(std::f32::consts::FRAC_PI_2);
            } else {
                transform.rotation = Quat::IDENTITY;
            }
        }
    }
}
}

Card Layout

Cards are laid out using a custom layout system that handles positioning, stacking, and organization:

#![allow(unused)]
fn main() {
fn position_battlefield_cards(
    mut card_query: Query<(Entity, &BattlefieldCard, &mut Transform)>,
    battlefield_query: Query<&Battlefield>,
) {
    if let Ok(battlefield) = battlefield_query.get_single() {
        let mut positions = calculate_card_positions(battlefield);
        
        for (entity, battlefield_card, mut transform) in &mut card_query {
            if let Some(position) = positions.get(&battlefield_card.position) {
                transform.translation = Vec3::new(position.x, position.y, battlefield_card.layer as f32);
            }
        }
    }
}
}

UI Components

Rummage uses Bevy's UI system for menus, dialogs, and game interface elements.

UI Structure

The UI is organized hierarchically:

  • Main UI root
    • Game UI (playmat, zones, etc.)
      • Player areas
      • Stack visualization
      • Phase indicator
    • Menu UI (game menu, settings, etc.)
    • Dialog UI (modal dialogs)
    • Tooltip UI (card info, ability info)

UI Components in Bevy 0.15

In Bevy 0.15, UI components use the new approach:

#![allow(unused)]
fn main() {
// Create a UI node
commands.spawn((
    // Node component instead of NodeBundle
    Node {
        style: Style {
            width: Val::Percent(100.0),
            height: Val::Percent(100.0),
            ..default()
        },
        background_color: Color::rgba(0.1, 0.1, 0.1, 0.8),
        ..default()
    },
    // Other components
    UIRoot,
));

// Create text
commands.spawn((
    // Text component instead of TextBundle
    Text {
        sections: vec![TextSection {
            value: "Player 1".to_string(),
            style: TextStyle {
                font: font.clone(),
                font_size: 24.0,
                color: Color::WHITE,
            },
        }],
        alignment: TextAlignment::Center,
        ..default()
    },
    // Style info
    Style {
        position_type: PositionType::Absolute,
        top: Val::Px(10.0),
        left: Val::Px(10.0),
        ..default()
    },
    // Additional components
    PlayerNameLabel(1),
));
}

Dynamic UI Updates

UI elements are updated based on game state:

#![allow(unused)]
fn main() {
fn update_phase_indicator(
    mut text_query: Query<&mut Text, With<PhaseIndicator>>,
    game_state: Res<GameState>,
) {
    if let Ok(mut text) = text_query.get_single_mut() {
        text.sections[0].value = format!("Phase: {}", game_state.current_phase.to_string());
    }
}
}

Visual Effects

Rummage includes a variety of visual effects to enhance the gaming experience.

Effect Components

Effects use dedicated components:

#![allow(unused)]
fn main() {
// Visual effect component
#[derive(Component)]
pub struct VisualEffect {
    pub effect_type: EffectType,
    pub duration: Timer,
    pub intensity: f32,
}

// Effect types
pub enum EffectType {
    CardGlow,
    Explosion,
    Sparkle,
    DamageFlash,
    HealingGlow,
}
}

Effect Systems

Effects are processed by dedicated systems:

#![allow(unused)]
fn main() {
fn process_visual_effects(
    mut commands: Commands,
    time: Res<Time>,
    mut effect_query: Query<(Entity, &mut VisualEffect, &mut Sprite)>,
) {
    for (entity, mut effect, mut sprite) in &mut effect_query {
        // Update effect timer
        effect.duration.tick(time.delta());
        
        // Calculate effect progress (0.0 to 1.0)
        let progress = effect.duration.percent();
        
        // Apply effect based on type
        match effect.effect_type {
            EffectType::CardGlow => {
                // Modify sprite color based on progress
                let intensity = (progress * std::f32::consts::PI).sin() * effect.intensity;
                sprite.color = sprite.color.with_a(0.5 + intensity * 0.5);
            },
            // Handle other effect types
            // ...
        }
        
        // Remove completed effects
        if effect.duration.finished() {
            commands.entity(entity).remove::<VisualEffect>();
            // Reset sprite to normal
            sprite.color = sprite.color.with_a(1.0);
        }
    }
}
}

Performance Optimization

Rendering can be resource-intensive, especially with many cards and effects. Rummage includes several optimizations:

Culling

Objects outside the view are culled to reduce rendering load:

#![allow(unused)]
fn main() {
fn cull_distant_cards(
    mut commands: Commands,
    camera_query: Query<(&Camera, &GlobalTransform)>,
    card_query: Query<(Entity, &GlobalTransform), With<Card>>,
) {
    if let Ok((camera, camera_transform)) = camera_query.get_single() {
        let camera_pos = camera_transform.translation().truncate();
        
        for (entity, transform) in &card_query {
            let distance = camera_pos.distance(transform.translation().truncate());
            
            // If card is too far away, disable its rendering
            if distance > MAX_CARD_RENDER_DISTANCE {
                commands.entity(entity).insert(Visibility::Hidden);
            } else {
                commands.entity(entity).insert(Visibility::Visible);
            }
        }
    }
}
}

Level of Detail

Cards far from the camera use simplified rendering:

#![allow(unused)]
fn main() {
fn adjust_card_detail(
    mut commands: Commands,
    camera_query: Query<(&Camera, &GlobalTransform)>,
    card_query: Query<(Entity, &GlobalTransform, &CardVisual)>,
) {
    if let Ok((camera, camera_transform)) = camera_query.get_single() {
        let camera_pos = camera_transform.translation().truncate();
        
        for (entity, transform, visual) in &card_query {
            let distance = camera_pos.distance(transform.translation().truncate());
            
            // Adjust detail level based on distance
            let detail_level = if distance < CLOSE_DETAIL_THRESHOLD {
                CardDetailLevel::High
            } else if distance < MEDIUM_DETAIL_THRESHOLD {
                CardDetailLevel::Medium
            } else {
                CardDetailLevel::Low
            };
            
            // Update detail level if changed
            if visual.detail_level != detail_level {
                commands.entity(entity).insert(CardDetailLevel(detail_level));
            }
        }
    }
}
}

Batching

Similar rendering operations are batched to reduce draw calls:

#![allow(unused)]
fn main() {
fn setup_material_batching(
    mut render_app: ResMut<App>,
    render_device: Res<RenderDevice>,
) {
    // Set up batched materials for cards with similar properties
    render_app.insert_resource(CardBatchingOptions {
        max_batch_size: 64,
        use_instancing: true,
    });
}
}

Best Practices

When working with rendering in Rummage, follow these best practices:

Asset Management

  • Preload assets: Use asset preprocessing to load common textures early
  • Texture atlases: Group related textures in atlases to reduce binding changes
  • Asset handles: Reuse asset handles instead of loading the same texture multiple times

Rendering Organization

  • Separation of concerns: Keep rendering logic separate from game logic
  • Component-based approach: Use components to define visual properties
  • System organization: Group related rendering systems together

UI Design

  • Responsive layouts: Design UI that adapts to different screen sizes
  • Consistent styling: Use consistent colors, fonts, and spacing
  • Performance awareness: Minimize UI elements and updates for better performance

Common Issues

Multiple Camera Issue

When using multiple cameras, queries might return multiple entities:

Problem:

#![allow(unused)]
fn main() {
// This will panic if there are multiple cameras
let (camera, transform) = camera_query.single();
}

Solution:

#![allow(unused)]
fn main() {
// Use a marker component to identify the main camera
#[derive(Component)]
struct MainCamera;

// Then query with the marker
let (camera, transform) = camera_query.get_single().unwrap_or_else(|_| {
    panic!("Expected exactly one main camera")
});
}

Z-Fighting

When cards or UI elements overlap, they might flicker due to z-fighting:

Problem:

#![allow(unused)]
fn main() {
// Cards at the same z position
transform.translation = Vec3::new(x, y, 0.0);
}

Solution:

#![allow(unused)]
fn main() {
// Assign incrementing z values
transform.translation = Vec3::new(x, y, layer_index as f32 * 0.01);
}

Texture Loading Errors

Missing textures can cause rendering issues:

Problem:

#![allow(unused)]
fn main() {
// No error handling for missing textures
let texture = asset_server.load(&format!("cards/{}.png", card.id));
}

Solution:

#![allow(unused)]
fn main() {
// Use a fallback texture
let texture_path = format!("cards/{}.png", card.id);
let texture_handle = asset_server.load(&texture_path);

// Set up a system to check for load errors
fn check_texture_loading(
    mut events: EventReader<AssetEvent<Image>>,
    mut card_query: Query<(&CardIdentifier, &Handle<Image>, &mut Visibility)>,
    asset_server: Res<AssetServer>,
) {
    for event in events.iter() {
        if let AssetEvent::LoadFailed(handle) = event {
            // Find cards with the failed texture and use fallback
            for (card_id, image_handle, mut visibility) in &mut card_query {
                if image_handle == handle {
                    // Load default texture instead
                    commands.entity(entity).insert(asset_server.load("cards/default.png"));
                }
            }
        }
    }
}
}

For questions or assistance with rendering in Rummage, please contact the development team.

Camera Management

This guide explains camera management in Rummage using Bevy, with a focus on best practices for handling multiple cameras and common camera-related issues.

Table of Contents

  1. Introduction to Cameras in Bevy
  2. Camera Architecture in Rummage
  3. Setting Up Cameras
  4. Accessing Camera Data
  5. Camera Controls
  6. Multiple Camera Management
  7. Camera Projection
  8. Common Camera Issues

Introduction to Cameras in Bevy

Cameras in Bevy define how the game world is viewed. They determine what is rendered and from what perspective. In Bevy, cameras are entities with camera-related components:

  • Camera: The core camera component that defines rendering properties
  • GlobalTransform: Position and orientation of the camera in world space
  • Projection: Orthographic or perspective projection settings
  • CameraRenderGraph: Defines what render graph the camera uses

In Bevy 0.15.x, cameras have been improved with better control over rendering order, layers, and viewport settings.

Camera Architecture in Rummage

Rummage uses a multi-camera setup to handle different views of the game:

  1. Main Game Camera: An orthographic camera that views the game board
  2. UI Camera: A specialized camera for UI elements
  3. Hand Camera: A dedicated camera for viewing the player's hand
  4. Detail Camera: A camera for viewing card details up close

Each camera is assigned specific render layers to control what they render:

#![allow(unused)]
fn main() {
// Render layers in Rummage
#[derive(Copy, Clone, Debug, Default, Component, Reflect)]
pub enum RenderLayer {
    #[default]
    Game = 0,       // Game elements (battlefield, etc.)
    UI = 1,         // UI elements
    Hand = 2,       // Hand cards
    CardDetail = 3, // Card detail view
}
}

Setting Up Cameras

Here's how cameras are set up in Rummage:

#![allow(unused)]
fn main() {
fn setup_cameras(mut commands: Commands) {
    // Main game camera (orthographic, top-down view)
    commands.spawn((
        Camera3dBundle {
            transform: Transform::from_translation(Vec3::new(0.0, 10.0, 0.0))
                .looking_at(Vec3::ZERO, Vec3::Z),
            projection: OrthographicProjection {
                scale: 3.0,
                ..default()
            }
            .into(),
            ..default()
        },
        // Important! Mark this as the main camera
        MainCamera,
        // Specify what this camera renders
        RenderLayers::from_layers(&[RenderLayer::Game as u8]),
    ));
    
    // UI camera
    commands.spawn((
        Camera2dBundle {
            camera: Camera {
                // UI camera renders after main camera
                order: 1,
                ..default()
            },
            ..default()
        },
        UiCamera,
        RenderLayers::from_layers(&[RenderLayer::UI as u8]),
    ));
    
    // Additional cameras as needed...
}
}

Accessing Camera Data

The most important practice when working with cameras in a multi-camera system is to use markers and filtered queries. This prevents the dreaded "MultipleEntities" panic that occurs when using single() or single_mut() with multiple cameras:

#![allow(unused)]
fn main() {
// Camera marker components
#[derive(Component)]
pub struct MainCamera;

#[derive(Component)]
pub struct UiCamera;

#[derive(Component)]
pub struct HandCamera;

// Correctly accessing a specific camera
fn process_main_camera(
    // Filter the query to only get the main camera
    main_camera_query: Query<(&Camera, &GlobalTransform), With<MainCamera>>,
) {
    // Use get_single() instead of single() for better error handling
    if let Ok((camera, transform)) = main_camera_query.get_single() {
        // Now we can safely work with the camera data
        let camera_position = transform.translation();
        // ...
    }
}
}

Camera Controls

Rummage implements several camera control systems:

Pan and Zoom

#![allow(unused)]
fn main() {
fn camera_pan_system(
    mut camera_query: Query<&mut Transform, With<MainCamera>>,
    input: Res<Input<MouseButton>>,
    mut motion_events: EventReader<MouseMotion>,
) {
    if input.pressed(MouseButton::Middle) {
        let mut camera_transform = match camera_query.get_single_mut() {
            Ok(transform) => transform,
            Err(_) => return, // Safely handle the error
        };
        
        for event in motion_events.iter() {
            let delta = event.delta;
            // Pan the camera based on mouse movement
            camera_transform.translation.x -= delta.x * PAN_SPEED;
            camera_transform.translation.y += delta.y * PAN_SPEED;
        }
    }
}

fn camera_zoom_system(
    mut camera_query: Query<(&mut OrthographicProjection, &mut Transform), With<MainCamera>>,
    mut scroll_events: EventReader<MouseWheel>,
) {
    if let Ok((mut projection, mut transform)) = camera_query.get_single_mut() {
        for event in scroll_events.iter() {
            // Zoom based on scroll direction
            projection.scale -= event.y * ZOOM_SPEED;
            // Clamp to reasonable values
            projection.scale = projection.scale.clamp(MIN_ZOOM, MAX_ZOOM);
        }
    }
}
}

Camera Transitions

For smooth transitions between camera views:

#![allow(unused)]
fn main() {
#[derive(Component)]
pub struct CameraTransition {
    pub target_position: Vec3,
    pub target_rotation: Quat,
    pub duration: f32,
    pub timer: Timer,
}

fn camera_transition_system(
    time: Res<Time>,
    mut commands: Commands,
    mut camera_query: Query<(Entity, &mut Transform, &mut CameraTransition)>,
) {
    for (entity, mut transform, mut transition) in &mut camera_query {
        transition.timer.tick(time.delta());
        let progress = transition.timer.percent();
        
        // Interpolate position and rotation
        transform.translation = transform.translation
            .lerp(transition.target_position, progress);
        transform.rotation = transform.rotation
            .slerp(transition.target_rotation, progress);
        
        // Remove the transition component when complete
        if transition.timer.finished() {
            commands.entity(entity).remove::<CameraTransition>();
        }
    }
}
}

Multiple Camera Management

When working with multiple cameras, follow these guidelines:

  1. Use marker components: Always attach marker components to differentiate cameras
  2. Filtered queries: Use query filters to target specific cameras
  3. Render layers: Assign render layers to control what each camera sees
  4. Render order: Set camera order to control rendering sequence
  5. Error handling: Use get_single() with error handling instead of single()

This system coordinates multiple cameras:

#![allow(unused)]
fn main() {
fn coordinate_cameras(
    card_detail_state: Res<State<CardDetailState>>,
    mut main_camera_query: Query<&mut Camera, (With<MainCamera>, Without<UiCamera>)>,
    mut ui_camera_query: Query<&mut Camera, With<UiCamera>>,
) {
    // Get cameras with proper error handling
    let mut main_camera = match main_camera_query.get_single_mut() {
        Ok(camera) => camera,
        Err(_) => return,
    };
    
    let mut ui_camera = match ui_camera_query.get_single_mut() {
        Ok(camera) => camera,
        Err(_) => return,
    };
    
    // Adjust cameras based on game state
    match card_detail_state.get() {
        CardDetailState::Viewing => {
            // While viewing a card detail, disable the main camera
            main_camera.is_active = false;
        }
        CardDetailState::None => {
            // When not viewing details, enable the main camera
            main_camera.is_active = true;
        }
    }
    
    // UI camera is always active
    ui_camera.is_active = true;
}
}

Camera Projection

Rummage uses orthographic projection for the main game camera, as it provides a clearer view of the card game board:

#![allow(unused)]
fn main() {
fn setup_orthographic_camera(mut commands: Commands) {
    commands.spawn((
        Camera3dBundle {
            projection: OrthographicProjection {
                scale: 3.0,
                scaling_mode: ScalingMode::FixedVertical(2.0),
                near: -1000.0,
                far: 1000.0,
                ..default()
            }
            .into(),
            transform: Transform::from_translation(Vec3::new(0.0, 10.0, 0.0))
                .looking_at(Vec3::ZERO, Vec3::Z),
            ..default()
        },
        MainCamera,
    ));
}
}

For specialized views like card details, a perspective camera might be used:

#![allow(unused)]
fn main() {
fn setup_perspective_camera(mut commands: Commands) {
    commands.spawn((
        Camera3dBundle {
            projection: PerspectiveProjection {
                fov: std::f32::consts::PI / 4.0,
                near: 0.1,
                far: 100.0,
                aspect_ratio: 1.0,
            }
            .into(),
            transform: Transform::from_translation(Vec3::new(0.0, 0.0, 10.0))
                .looking_at(Vec3::ZERO, Vec3::Y),
            ..default()
        },
        DetailCamera,
    ));
}
}

Common Camera Issues

MultipleEntities Error

The most common camera-related error is the "MultipleEntities" panic, which occurs when multiple entities match a camera query that expects a single result:

Problem:

#![allow(unused)]
fn main() {
fn problematic_camera_system(
    camera_query: Query<(&Camera, &GlobalTransform)>,
) {
    // This will panic if there are multiple cameras
    let (camera, transform) = camera_query.single();
    // ...
}
}

Solution:

#![allow(unused)]
fn main() {
// Add a marker component to your cameras
#[derive(Component)]
struct MainCamera;

// Then query with the marker
fn fixed_camera_system(
    camera_query: Query<(&Camera, &GlobalTransform), With<MainCamera>>,
) {
    if let Ok((camera, transform)) = camera_query.get_single() {
        // Now we only get the camera with the MainCamera marker
    } else {
        // Handle the error case gracefully
        warn!("Expected exactly one main camera");
    }
}
}

Incorrect View Frustum

If objects aren't visible when they should be, check the camera's near and far planes:

Problem:

#![allow(unused)]
fn main() {
// Objects might be outside the camera's view frustum
OrthographicProjection {
    near: 0.1,
    far: 100.0,
    // ...
}
}

Solution:

#![allow(unused)]
fn main() {
// Use more generous near/far values for card games
OrthographicProjection {
    near: -1000.0,  // Allow objects "behind" the camera in orthographic view
    far: 1000.0,    // See objects far away
    // ...
}
}

Camera Depth Issues

Objects appearing in unexpected order:

Problem:

#![allow(unused)]
fn main() {
// Z-fighting or depth order issues
transform.translation = Vec3::new(x, y, 0.0);
}

Solution:

#![allow(unused)]
fn main() {
// Use the z-coordinate for explicit depth ordering
transform.translation = Vec3::new(x, y, layer * 0.1);

// Or adjust the camera's transform
camera_transform.translation = Vec3::new(0.0, 0.0, z_distance);
camera_transform.look_at(Vec3::ZERO, Vec3::Y);
}

Next: Handling Game State

Random Number Generation with bevy_rand

This guide explains how to implement deterministic random number generation in Rummage using bevy_rand and bevy_prng, with a focus on entity-attached RNGs for networked state synchronization.

Table of Contents

  1. Overview
  2. Setup and Configuration
  3. Entity-Attached RNGs
  4. Networked State Synchronization
  5. Testing and Debugging
  6. Implementation Patterns
  7. Performance Considerations

Overview

In Rummage, deterministic random number generation is critical for:

  1. Networked Gameplay: Ensuring all clients produce identical results when processing the same game actions
  2. Replay Functionality: Allowing game sessions to be accurately replayed
  3. Testing: Creating reproducible test scenarios

The bevy_rand ecosystem provides the tools we need to implement deterministic RNG that can be:

  • Attached to specific entities
  • Serialized/deserialized for network transmission
  • Rolled back and restored for state reconciliation

Setup and Configuration

Dependencies

In your Cargo.toml, include the following dependencies:

[dependencies]
bevy_rand = { git = "https://github.com/Bluefinger/bevy_rand", branch = "main", features = ["wyrand", "experimental"] }
bevy_prng = { git = "https://github.com/Bluefinger/bevy_rand", branch = "main", features = ["wyrand"] }

The wyrand feature specifies the WyRand algorithm, which provides a good balance of performance and quality. The experimental feature enables bevy_rand's entity-attached RNG functionality.

Plugin Registration

Set up the RNG system in your app:

use bevy::prelude::*;
use bevy_prng::WyRand;
use bevy_rand::prelude::*;

fn main() {
    App::new()
        // Add the entropy plugin with WyRand algorithm
        .add_plugins(EntropyPlugin::<WyRand>::default())
        // Your other plugins...
        .run();
}

Seeding for Determinism

For deterministic behavior, seed the global RNG at the start of your game:

#![allow(unused)]
fn main() {
fn setup_deterministic_rng(mut global_entropy: ResMut<GlobalEntropy<WyRand>>) {
    // Use a fixed seed for testing or derive from game parameters
    let seed = 12345u64;
    global_entropy.seed_from_u64(seed);
}
}

For multiplayer games, the server should generate the seed and communicate it to clients during game initialization.

Entity-Attached RNGs

Core Concept

Entity-attached RNGs allow different game entities (players, decks, etc.) to have their own independent but deterministic random number generators. This is critical for:

  1. Isolation: Each entity's randomization is independent of others
  2. Reproducibility: Given the same initial state, entities will produce the same sequence of random numbers
  3. State Management: Entity RNG state can be saved, restored, and synchronized

Creating Entity-Attached RNGs

#![allow(unused)]
fn main() {
use bevy::prelude::*;
use bevy_prng::WyRand;
use bevy_rand::prelude::*;

// Component to hold an entity's RNG
#[derive(Component)]
struct EntityRng(Entropy<WyRand>);

// System to set up RNGs for entities that need them
fn setup_entity_rngs(
    mut commands: Commands,
    entities: Query<Entity, (With<Player>, Without<EntityRng>)>,
    mut global_entropy: ResMut<GlobalEntropy<WyRand>>,
) {
    for entity in &entities {
        // Create a new RNG forked from the global entropy source
        let entity_rng = global_entropy.fork_rng();
        
        // Attach it to the entity
        commands.entity(entity).insert(EntityRng(entity_rng));
    }
}
}

Using Entity-Attached RNGs

To use an entity's RNG:

#![allow(unused)]
fn main() {
fn shuffle_player_deck(
    mut players: Query<(&Player, &mut EntityRng)>,
    mut decks: Query<&mut Deck>,
) {
    for (player, mut entity_rng) in &mut players {
        if let Ok(mut deck) = decks.get_mut(player.deck_entity) {
            // Use the player's RNG to shuffle their deck
            deck.shuffle_with_rng(&mut entity_rng.0);
        }
    }
}
}

Networked State Synchronization

Serializing RNG State

For network transmission, RNG state must be serialized:

#![allow(unused)]
fn main() {
// Resource to track RNG state for network sync
#[derive(Resource)]
struct NetworkedRngState {
    // Global RNG state
    global_state: Vec<u8>,
    // Player entity RNG states mapped by entity ID
    entity_states: HashMap<Entity, Vec<u8>>,
    // Last sync timestamp
    last_sync: f32,
}

// System to capture RNG states for replication
fn capture_rng_states(
    global_entropy: Res<GlobalEntropy<WyRand>>,
    entity_rngs: Query<(Entity, &EntityRng)>,
    mut networked_state: ResMut<NetworkedRngState>,
    time: Res<Time>,
) {
    // Only sync periodically to reduce network traffic
    if time.elapsed_seconds() - networked_state.last_sync < 5.0 {
        return;
    }
    
    // Capture global RNG state
    if let Ok(serialized) = global_entropy.try_serialize_state() {
        networked_state.global_state = serialized;
    }
    
    // Capture entity RNG states
    for (entity, entity_rng) in &entity_rngs {
        if let Ok(serialized) = entity_rng.0.try_serialize_state() {
            networked_state.entity_states.insert(entity, serialized);
        }
    }
    
    networked_state.last_sync = time.elapsed_seconds();
}
}

Transmitting RNG State

Use Bevy Replicon to efficiently sync RNG state between server and clients:

#![allow(unused)]
fn main() {
use bevy_replicon::prelude::*;

// Server-authoritative replication
#[derive(Component, Serialize, Deserialize, Clone)]
struct ReplicatedRngState {
    state: Vec<u8>,
    last_updated: f32,
}

// System to update replication components
fn update_rng_replication(
    mut commands: Commands,
    players: Query<(Entity, &EntityRng)>,
    time: Res<Time>,
) {
    for (entity, entity_rng) in &players {
        if let Ok(serialized) = entity_rng.0.try_serialize_state() {
            commands.entity(entity).insert(ReplicatedRngState {
                state: serialized,
                last_updated: time.elapsed_seconds(),
            });
        }
    }
}
}

Restoring RNG State on Clients

#![allow(unused)]
fn main() {
// System to apply RNG state updates from server
fn apply_rng_state_updates(
    mut players: Query<(Entity, &ReplicatedRngState, &mut EntityRng)>,
    mut applied_states: Local<HashMap<Entity, f32>>,
) {
    for (entity, replicated_state, mut entity_rng) in &mut players {
        // Check if this is a newer state than what we've already applied
        if !applied_states.contains_key(&entity) || 
           applied_states[&entity] < replicated_state.last_updated {
            
            // Apply the updated state
            if let Ok(()) = entity_rng.0.deserialize_state(&replicated_state.state) {
                applied_states.insert(entity, replicated_state.last_updated);
            }
        }
    }
}
}

Testing and Debugging

Verifying Determinism

To verify RNG determinism, create a test that:

  1. Seeds multiple RNGs with the same seed
  2. Generates a sequence of random values from each
  3. Compares the sequences for equality
#![allow(unused)]
fn main() {
#[test]
fn test_rng_determinism() {
    // Create two separate RNGs with the same seed
    let seed = 12345u64;
    let mut rng1 = WyRand::seed_from_u64(seed);
    let mut rng2 = WyRand::seed_from_u64(seed);
    
    // Generate sequences from both RNGs
    let sequence1: Vec<u32> = (0..100).map(|_| rng1.gen_range(0..1000)).collect();
    let sequence2: Vec<u32> = (0..100).map(|_| rng2.gen_range(0..1000)).collect();
    
    // Verify sequences are identical
    assert_eq!(sequence1, sequence2, "RNG sequences should be identical with the same seed");
}
}

Debugging Network Desynchronization

When RNG state gets out of sync across the network:

  1. Add Logging: Log RNG states and the random values they generate

    #![allow(unused)]
    fn main() {
    info!("Entity {}: RNG state hash: {:?}, Next value: {}", 
        entity, hash_rng_state(&entity_rng.0), entity_rng.0.gen_range(0..100));
    }
  2. State Comparison: Compare serialized RNG states between server and clients

    #![allow(unused)]
    fn main() {
    fn debug_rng_states(
        server_states: &HashMap<Entity, Vec<u8>>,
        client_states: &HashMap<Entity, Vec<u8>>,
    ) {
        for (entity, server_state) in server_states {
            if let Some(client_state) = client_states.get(entity) {
                if server_state != client_state {
                    warn!("RNG state mismatch for entity {}", entity);
                }
            }
        }
    }
    }
  3. Event Logging: Track every action that uses RNG to pinpoint where divergence occurs

Implementation Patterns

Deck Shuffling

For card games like MTG, deck shuffling must be consistent across the network:

#![allow(unused)]
fn main() {
// Component for a deck of cards
#[derive(Component)]
struct Deck {
    cards: Vec<Entity>,
}

// System to shuffle a deck using entity-attached RNG
fn shuffle_deck(
    mut decks: Query<&mut Deck>,
    deck_owner: Query<&DeckOwner>,
    mut players: Query<&mut EntityRng>,
    mut shuffle_events: EventReader<ShuffleDeckEvent>,
) {
    for event in shuffle_events.iter() {
        // Get the deck
        if let Ok(mut deck) = decks.get_mut(event.deck_entity) {
            // Find the deck owner
            if let Ok(owner) = deck_owner.get(event.deck_entity) {
                // Get the owner's RNG
                if let Ok(mut entity_rng) = players.get_mut(owner.0) {
                    // Use Fisher-Yates shuffle with the owner's RNG
                    let mut cards = deck.cards.clone();
                    for i in (1..cards.len()).rev() {
                        let j = entity_rng.0.gen_range(0..=i);
                        cards.swap(i, j);
                    }
                    deck.cards = cards;
                }
            }
        }
    }
}
}

Random Card Selection

For abilities that select random targets:

#![allow(unused)]
fn main() {
fn random_target_selection(
    mut commands: Commands,
    mut ability_events: EventReader<AbilityActivatedEvent>,
    players: Query<&EntityRng>,
    targets: Query<Entity, With<Targetable>>,
) {
    for event in ability_events.iter() {
        if event.ability_type == AbilityType::RandomTarget {
            // Get the entity's RNG
            if let Ok(entity_rng) = players.get(event.player_entity) {
                let target_entities: Vec<Entity> = targets.iter().collect();
                
                if !target_entities.is_empty() {
                    // Select a random target using the entity's RNG
                    let random_index = entity_rng.0.gen_range(0..target_entities.len());
                    let selected_target = target_entities[random_index];
                    
                    // Apply the ability effect to the selected target
                    commands.entity(selected_target).insert(AbilityEffect {
                        source: event.player_entity,
                        effect_type: event.effect_type,
                    });
                }
            }
        }
    }
}
}

Performance Considerations

Minimizing RNG Operations

Random number generation can be computationally expensive:

  1. Cache Random Results: Generate batches of random values when possible

    #![allow(unused)]
    fn main() {
    // Generate and cache random values
    for _ in 0..10 {
        let value = entity_rng.0.gen_range(0..100);
        cached_values.push(value);
    }
    }
  2. Optimize RNG Distribution: Use the most efficient distribution for your needs

    #![allow(unused)]
    fn main() {
    // For uniform integer distributions, use gen_range
    let value = rng.gen_range(0..100);
    
    // For weighted choices, use a weight-optimized approach
    let choices = vec![(option1, 10), (option2, 5), (option3, 1)];
    let total_weight: u32 = choices.iter().map(|(_, w)| w).sum();
    let mut rng_value = rng.gen_range(0..total_weight);
    
    for (option, weight) in choices {
        if rng_value < *weight {
            selected = option;
            break;
        }
        rng_value -= *weight;
    }
    }
  3. Schedule RNG Operations: Spread intensive RNG work across frames

State Synchronization Frequency

Synchronize RNG state efficiently:

  1. Event-Driven Updates: Sync after significant random events rather than on a timer
  2. Delta Compression: Only send changes to RNG state
  3. Prioritize Critical Entities: Sync more frequently for gameplay-critical entities

By following these guidelines, you can create a robust, deterministic random number generation system that works reliably across network boundaries in your Bevy application.

Bevy 0.15.x API Deprecations Guide

This guide documents the deprecated APIs in Bevy 0.15.x and their recommended replacements for use in the Rummage project.

Bundle Deprecations

Bevy 0.15.x has deprecated many bundle types in favor of a more streamlined component approach.

Rendering Bundles

DeprecatedReplacement
Text2dBundleText2d component
SpriteBundleSprite component
ImageBundleImage component

Example:

#![allow(unused)]
fn main() {
// ❌ Deprecated approach
commands.spawn(SpriteBundle {
    texture: asset_server.load("path/to/sprite.png"),
    transform: Transform::from_translation(Vec3::new(0.0, 0.0, 0.0)),
    ..default()
});

// ✅ New approach
commands.spawn((
    Sprite::default(),
    asset_server.load::<Image>("path/to/sprite.png"),
    Transform::from_translation(Vec3::new(0.0, 0.0, 0.0)),
));
}

UI Bundles

DeprecatedReplacement
NodeBundleNode component
ButtonBundleCombine Button with other components
TextBundleCombine Text and other components

Example:

#![allow(unused)]
fn main() {
// ❌ Deprecated approach
commands.spawn(NodeBundle {
    style: Style {
        width: Val::Px(200.0),
        height: Val::Px(100.0),
        ..default()
    },
    background_color: Color::WHITE.into(),
    ..default()
});

// ✅ New approach
commands.spawn((
    Node {
        // Node properties
    },
    Style {
        width: Val::Px(200.0),
        height: Val::Px(100.0),
        ..default()
    },
    BackgroundColor(Color::WHITE),
));
}

Camera Bundles

DeprecatedReplacement
Camera2dBundleCamera2d component
Camera3dBundleCamera3d component

Example:

#![allow(unused)]
fn main() {
// ❌ Deprecated approach
commands.spawn(Camera2dBundle::default());

// ✅ New approach
commands.spawn(Camera2d::default());
}

Transform Bundles

DeprecatedReplacement
SpatialBundleCombine Transform and Visibility
TransformBundleTransform component
GlobalTransform (manual insertion)Just insert Transform

Example:

#![allow(unused)]
fn main() {
// ❌ Deprecated approach
commands.spawn(SpatialBundle::default());

// ✅ New approach
commands.spawn((
    Transform::default(),
    Visibility::default(),
));
}

Required Component Pattern

Bevy 0.15.x uses a "required component" pattern where inserting certain components will automatically insert their prerequisites. This means you no longer need to explicitly add all dependencies.

Examples of Required Component Pattern

#![allow(unused)]
fn main() {
// Camera2d will automatically add Camera, Transform, and other required components
commands.spawn(Camera2d::default());

// Transform will automatically add GlobalTransform
commands.spawn(Transform::default());

// Sprite will automatically add TextureAtlas related components
commands.spawn(Sprite::default());
}

Event API Changes

DeprecatedReplacement
Events::get_reader()Events::get_cursor()
ManualEventReaderEventCursor

Example:

#![allow(unused)]
fn main() {
// ❌ Deprecated approach
let mut reader = events.get_reader();
for event in reader.read(&events) {
    // Handle event
}

// ✅ New approach
let mut cursor = events.get_cursor();
for event in cursor.read(&events) {
    // Handle event
}
}

Entity Access Changes

DeprecatedReplacement
Commands.get_or_spawn()Commands.entity() or Commands.spawn()
World.get_or_spawn()World.entity() or World.spawn()

Example:

#![allow(unused)]
fn main() {
// ❌ Deprecated approach
commands.get_or_spawn(entity).insert(MyComponent);

// ✅ New approach
if world.contains_entity(entity) {
    commands.entity(entity).insert(MyComponent);
} else {
    commands.spawn((entity, MyComponent));
}
}

UI Node Access Changes

DeprecatedReplacement
Node::logical_rect()Rect::from_center_size with translation and node size
Node::physical_rect()Rect::from_center_size with translation and node size

Example:

#![allow(unused)]
fn main() {
// ❌ Deprecated approach
let rect = node.logical_rect(transform);

// ✅ New approach
let rect = Rect::from_center_size(
    transform.translation().truncate(),
    node.size(),
);
}

Window and Input Changes

DeprecatedReplacement
CursorIcon field in WindowCursorIcon component on window entity
Window.add_*_listener methodsUse event systems

Example:

#![allow(unused)]
fn main() {
// ❌ Deprecated approach
window.cursor_icon = CursorIcon::Hand;

// ✅ New approach
commands.entity(window_entity).insert(CursorIcon::Hand);
}

Best Practices

  1. Check Compiler Warnings: Always check for compiler warnings after making changes, as they will indicate usage of deprecated APIs.
  2. Use Component Approach: Prefer the component-based approach over bundles.
  3. Required Components: Leverage Bevy's automatic insertion of required components.
  4. Run Tests: Run tests frequently to ensure compatibility.

Troubleshooting

If you encounter issues after replacing deprecated APIs:

  1. Check Component Dependencies: Some components may have implicit dependencies that need to be explicitly added.
  2. Verify Insertion Order: In some cases, the order of component insertion matters.
  3. Update Queries: Update your queries to match the new component structure.
  4. Check Bevy Changelog: Refer to the Bevy changelog for detailed API changes.

Persistent Storage with bevy_persistent

This guide provides an overview of using bevy_persistent for data persistence in Rummage.

Introduction

bevy_persistent is a Bevy crate that makes it easy to save and load data across application sessions. It enables robust persistence for:

  • User settings
  • Game saves
  • Deck collections
  • Game state snapshots
  • Player profiles
  • Achievement data

Getting Started

Adding the Dependency

First, add bevy_persistent to your Cargo.toml:

[dependencies]
bevy_persistent = "0.4.0"  # Use the latest compatible version

Basic Usage

The basic pattern for using bevy_persistent is:

use bevy::prelude::*;
use bevy_persistent::prelude::*;
use serde::{Deserialize, Serialize};

// Define a persistent resource
#[derive(Resource, Serialize, Deserialize, Default)]
struct GameSettings {
    volume: f32,
    fullscreen: bool,
    resolution: (u32, u32),
}

fn main() {
    App::new()
        .add_plugins(DefaultPlugins)
        // Initialize persistent resource
        .insert_resource(
            Persistent::<GameSettings>::builder()
                .name("settings")
                .format(StorageFormat::Ron)
                .path("user://settings.ron")
                .default(GameSettings {
                    volume: 0.5,
                    fullscreen: false,
                    resolution: (1920, 1080),
                })
                .build()
        )
        .add_systems(Startup, load_settings)
        .add_systems(Update, save_settings_on_change)
        .run();
}

// Load settings at startup
fn load_settings(mut settings: ResMut<Persistent<GameSettings>>) {
    if let Err(err) = settings.load() {
        error!("Failed to load settings: {}", err);
    } else {
        info!("Settings loaded successfully");
    }
}

// Save settings when they change
fn save_settings_on_change(settings: Res<Persistent<GameSettings>>) {
    if settings.is_changed() {
        if let Err(err) = settings.save() {
            error!("Failed to save settings: {}", err);
        } else {
            info!("Settings saved successfully");
        }
    }
}

Key Features

Builder Pattern

The library uses a builder pattern for constructing persistent resources:

#![allow(unused)]
fn main() {
Persistent::<T>::builder()
    .name("resource_name")         // Human-readable name
    .format(StorageFormat::Ron)    // Serialization format
    .path("user://file.ron")       // Storage path
    .default(T::default())         // Default value
    .build()
}

Storage Formats

bevy_persistent supports multiple storage formats:

  • Ron: Human-readable Rusty Object Notation (good for configs)
  • Json: Standard JSON format (good for interoperability)
  • Bincode: Efficient binary format (good for large data)
  • Toml: Config-friendly format (good for settings)
  • Yaml: Human-readable structured format

Storage Paths

Paths can use special prefixes:

  • user://: User-specific data directory
  • config://: Configuration directory
  • cache://: Cache directory
  • assets://: Assets directory

For example:

#![allow(unused)]
fn main() {
.path("user://saves/profile1.save")
}

Hot Reloading

During development, you can enable hot reloading of persistent resources:

#![allow(unused)]
fn main() {
fn hot_reload_system(mut settings: ResMut<Persistent<GameSettings>>) {
    if settings.was_modified_on_disk() {
        if let Err(err) = settings.load() {
            error!("Failed to hot reload settings: {}", err);
        } else {
            info!("Hot reloaded settings from disk");
        }
    }
}
}

Error Handling

The library provides comprehensive error handling:

#![allow(unused)]
fn main() {
match settings.load() {
    Ok(()) => info!("Loaded successfully"),
    Err(PersistentError::Io(err)) => error!("I/O error: {}", err),
    Err(PersistentError::Deserialize(err)) => error!("Deserialization error: {}", err),
    Err(err) => error!("Other error: {}", err),
}
}

Best Practices

Atomicity

To ensure atomic updates (all-or-nothing):

#![allow(unused)]
fn main() {
// Make multiple changes in a transaction-like manner
fn update_settings(mut settings: ResMut<Persistent<GameSettings>>) {
    // Make changes
    settings.volume = 0.8;
    settings.fullscreen = true;
    settings.resolution = (3840, 2160);
    
    // Save all changes at once
    if let Err(err) = settings.save() {
        error!("Failed to save settings: {}", err);
        // Optionally revert changes on error
    }
}
}

Versioning

For schema changes, use serde's versioning support:

#![allow(unused)]
fn main() {
#[derive(Resource, Serialize, Deserialize)]
struct GameSettings {
    // Add version field for schema migration
    #[serde(default = "default_version")]
    version: u32,
    
    // Original fields
    volume: f32,
    fullscreen: bool,
    
    // New fields with defaults
    #[serde(default)]
    resolution: (u32, u32),
}

fn default_version() -> u32 { 1 }
}

Resource Granularity

Choose the right granularity for persistent resources:

  • Too coarse: One resource for all settings makes recovery harder
  • Too fine: Too many small resources increases I/O overhead

Good balance:

#![allow(unused)]
fn main() {
// Audio settings in one resource
#[derive(Resource, Serialize, Deserialize, Default)]
struct AudioSettings { /* ... */ }

// Video settings in another
#[derive(Resource, Serialize, Deserialize, Default)]
struct VideoSettings { /* ... */ }

// Controls in another
#[derive(Resource, Serialize, Deserialize, Default)]
struct ControlSettings { /* ... */ }
}

Change Detection

For efficient saving, only save when something has actually changed:

#![allow(unused)]
fn main() {
fn save_if_changed(
    settings: Res<Persistent<GameSettings>>,
    time: Res<Time>,
    mut last_save: Local<f64>,
) {
    // Only check if resource changed
    if settings.is_changed() {
        let now = time.elapsed_seconds_f64();
        // Don't save too frequently (debounce)
        if now - *last_save > 5.0 {
            if let Err(err) = settings.save() {
                error!("Failed to save: {}", err);
            } else {
                *last_save = now;
            }
        }
    }
}
}

Use Cases in Rummage

Deck Database

For the deck database, use bevy_persistent to store user decks:

#![allow(unused)]
fn main() {
// See: docs/card_systems/deck_database/persistent_storage.md
}

Game State Snapshots

For game state snapshots and rollback functionality:

#![allow(unused)]
fn main() {
// See: docs/core_systems/snapshot/bevy_persistent_integration.md
}

User Settings

For user preferences and settings:

#![allow(unused)]
fn main() {
#[derive(Resource, Serialize, Deserialize, Default)]
struct UserPreferences {
    username: String,
    card_style: CardStyle,
    audio_volume: f32,
    enable_animations: bool,
    enable_auto_tap: bool,
    enable_hints: bool,
}

// Initialize in plugin
fn build_user_settings(app: &mut App) {
    let preferences = Persistent::<UserPreferences>::builder()
        .name("user_preferences")
        .format(StorageFormat::Ron)
        .path("user://preferences.ron")
        .default(UserPreferences::default())
        .build();
        
    app.insert_resource(preferences)
       .add_systems(Startup, load_user_preferences)
       .add_systems(Update, save_preferences_on_change);
}
}

Troubleshooting

Common Issues

  1. File Not Found: Check if the directory exists and has write permissions
  2. Serialization Errors: Make sure all fields are serializable
  3. Path Resolution: Use the correct path prefix for different platforms

Debugging Tips

Enable logging to debug storage issues:

#![allow(unused)]
fn main() {
// Enable debug logs for bevy_persistent
fn setup_logging() {
    env_logger::Builder::from_default_env()
        .filter_module("bevy_persistent", log::LevelFilter::Debug)
        .init();
}
}

Testing Integration

API Reference

This section provides detailed documentation for the Rummage game engine API, including core types, components, systems, and their relationships.

Overview

The Rummage API is built around Bevy's Entity Component System (ECS) architecture, organizing game elements through components, resources, and systems. This documentation helps developers understand how these pieces fit together to implement MTG Commander gameplay.

Core Modules

The API is organized into the following core modules:

  • Game Engine - Core game logic, rules implementation, and state management
  • Card Systems - Card representation, effects, and interactions
  • Player - Player state, resources, and interactions
  • UI - Game interface and visual elements
  • Networking - Multiplayer functionality and state synchronization

Game Engine APIs

The game engine implements the core MTG rules through several interconnected modules:

  • Actions - Game actions and their resolution
  • State - Game state tracking and transitions
  • Zones - Game zones (library, hand, battlefield, etc.)
  • Stack - The MTG stack and priority system
  • Turns & Phases - Turn structure and phase progression
  • Combat - Combat mechanics and damage resolution
  • Commander - Commander-specific rules implementation

How to Use This Documentation

  • Core Types - Fundamental types like GameState, Phase, etc.
  • Component Reference - Documentation for all ECS components
  • System Reference - Documentation for all ECS systems and their functions
  • Resource Reference - Documentation for game resources and global state

For implementation details, see the corresponding sections in the MTG Core Rules and Commander Format documentation.

Core Types

This document provides a reference for the fundamental data types used in Rummage.

Game State Types

Phase and Step Types

#![allow(unused)]
fn main() {
/// The phases of a Magic: The Gathering turn
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, Reflect)]
pub enum Phase {
    /// Beginning phase
    Beginning,
    /// First main phase
    PreCombatMain,
    /// Combat phase
    Combat,
    /// Second main phase
    PostCombatMain,
    /// Ending phase
    Ending,
}

/// The steps within phases of a Magic: The Gathering turn
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, Reflect)]
pub enum Step {
    /// Beginning phase - Untap step
    Untap,
    /// Beginning phase - Upkeep step
    Upkeep,
    /// Beginning phase - Draw step
    Draw,
    /// Combat phase - Beginning of combat step
    BeginningOfCombat,
    /// Combat phase - Declare attackers step
    DeclareAttackers,
    /// Combat phase - Declare blockers step
    DeclareBlockers,
    /// Combat phase - First strike damage step (only if needed)
    FirstStrikeDamage,
    /// Combat phase - Combat damage step
    CombatDamage,
    /// Combat phase - End of combat step
    EndOfCombat,
    /// Ending phase - End step
    End,
    /// Ending phase - Cleanup step
    Cleanup,
}
}

Zone Types

#![allow(unused)]
fn main() {
/// Game zones in Magic: The Gathering
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Reflect)]
pub enum ZoneType {
    /// The library (deck) zone
    Library,
    /// The hand zone
    Hand,
    /// The battlefield zone (permanents in play)
    Battlefield,
    /// The graveyard zone (discard pile)
    Graveyard,
    /// The stack zone (spells and abilities being cast/activated)
    Stack,
    /// The exile zone (removed from game)
    Exile,
    /// The command zone (for commanders, emblems, etc.)
    Command,
    /// The sideboard zone (unused in Commander)
    Sideboard,
}
}

Card Types

Card Type System

#![allow(unused)]
fn main() {
/// Types of Magic: The Gathering cards
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Reflect)]
pub struct CardTypes {
    /// Card super types (Legendary, Basic, etc.)
    pub super_types: HashSet<String>,
    /// Card types (Creature, Instant, etc.)
    pub card_types: HashSet<String>,
    /// Card sub types (Human, Wizard, Equipment, etc.)
    pub sub_types: HashSet<String>,
}

impl CardTypes {
    /// Constant for the Creature type
    pub const TYPE_CREATURE: Self = Self { 
        card_types: HashSet::from(["Creature".to_string()]),
        super_types: HashSet::new(),
        sub_types: HashSet::new(),
    };
    
    /// Create a new creature type
    pub fn new_creature(creature_types: Vec<String>) -> Self {
        Self {
            card_types: HashSet::from(["Creature".to_string()]),
            super_types: HashSet::new(),
            sub_types: HashSet::from_iter(creature_types),
        }
    }
    
    /// Check if this is a creature
    pub fn is_creature(&self) -> bool {
        self.card_types.contains("Creature")
    }
    
    /// Get creature types
    pub fn get_creature_types(&self) -> Vec<String> {
        if !self.is_creature() {
            return Vec::new();
        }
        self.sub_types.iter().cloned().collect()
    }
    
    // Similar methods for other card types
}
}

Card Details

#![allow(unused)]
fn main() {
/// Details specific to different card types
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Reflect)]
pub enum CardDetails {
    /// Creature card details
    Creature(CreatureCard),
    /// Planeswalker card details
    Planeswalker { loyalty: i32 },
    /// Instant card details
    Instant(SpellCard),
    /// Sorcery card details
    Sorcery(SpellCard),
    /// Enchantment card details
    Enchantment(EnchantmentCard),
    /// Artifact card details
    Artifact(ArtifactCard),
    /// Land card details
    Land(LandCard),
    /// Other card types
    Other,
}

/// Creature card details
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Reflect)]
pub struct CreatureCard {
    /// Creature's power
    pub power: i32,
    /// Creature's toughness
    pub toughness: i32,
    /// Creature's type
    pub creature_type: CreatureType,
}
}

Mana System

Mana Types

#![allow(unused)]
fn main() {
/// Representation of mana in Magic: The Gathering
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Reflect)]
pub struct Mana {
    /// Generic/colorless mana
    pub generic: u32,
    /// White mana
    pub white: u32,
    /// Blue mana
    pub blue: u32,
    /// Black mana
    pub black: u32,
    /// Red mana
    pub red: u32,
    /// Green mana
    pub green: u32,
}

impl Mana {
    /// Create a new Mana value
    pub fn new(generic: u32, white: u32, blue: u32, black: u32, red: u32, green: u32) -> Self {
        Self {
            generic,
            white,
            blue,
            black,
            red,
            green,
        }
    }
    
    /// Calculate the converted mana cost (total mana value)
    pub fn converted_mana_cost(&self) -> u32 {
        self.generic + self.white + self.blue + self.black + self.red + self.green
    }
    
    /// Get the amount of a specific color
    pub fn colored_mana_cost(&self, color: Color) -> u32 {
        match color {
            Color::Generic => self.generic,
            Color::White => self.white,
            Color::Blue => self.blue,
            Color::Black => self.black,
            Color::Red => self.red,
            Color::Green => self.green,
        }
    }
}

/// Color in Magic: The Gathering
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Reflect)]
pub enum Color {
    /// Generic/colorless mana
    Generic,
    /// White mana
    White,
    /// Blue mana
    Blue,
    /// Black mana
    Black,
    /// Red mana
    Red,
    /// Green mana
    Green,
}
}

Keyword Abilities

#![allow(unused)]
fn main() {
/// Keyword abilities in Magic: The Gathering
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, Reflect)]
pub enum KeywordAbility {
    /// Flying
    Flying,
    /// First Strike
    FirstStrike,
    /// Double Strike
    DoubleStrike,
    /// Deathtouch
    Deathtouch,
    /// Lifelink
    Lifelink,
    /// Haste
    Haste,
    /// Hexproof
    Hexproof,
    /// Indestructible
    Indestructible,
    /// Menace
    Menace,
    /// Protection
    Protection,
    /// Reach
    Reach,
    /// Trample
    Trample,
    /// Vigilance
    Vigilance,
    // And many more...
}

/// Container for a card's keyword abilities
#[derive(Debug, Clone, Default, Serialize, Deserialize, Reflect)]
pub struct KeywordAbilities {
    /// Set of abilities this card has
    pub abilities: HashSet<KeywordAbility>,
    /// Values for abilities that need them (e.g., "Protection from black")
    pub ability_values: HashMap<KeywordAbility, String>,
}

impl KeywordAbilities {
    /// Parse keywords from rules text
    pub fn from_rules_text(rules_text: &str) -> Self {
        // Implementation that parses keywords from rules text
        let mut abilities = HashSet::new();
        let mut ability_values = HashMap::new();
        
        // Example parsing logic
        if rules_text.contains("Flying") {
            abilities.insert(KeywordAbility::Flying);
        }
        
        if rules_text.contains("First strike") {
            abilities.insert(KeywordAbility::FirstStrike);
        }
        
        // Handle Protection keyword with value
        if let Some(protection_text) = rules_text.find("Protection ") {
            abilities.insert(KeywordAbility::Protection);
            // Extract the protection value (e.g., "from black")
            // and store it in ability_values
        }
        
        Self { abilities, ability_values }
    }
}
}

Stack and Effects

#![allow(unused)]
fn main() {
/// An item on the stack
#[derive(Debug, Clone, Serialize, Deserialize, Reflect)]
pub struct StackItem {
    /// Unique identifier
    pub id: Uuid,
    /// Source entity of the stack item
    pub source: Entity,
    /// Controller of the stack item
    pub controller: Entity,
    /// Type of stack item
    pub item_type: StackItemType,
    /// Target entities
    pub targets: Vec<Entity>,
    /// Effects to apply when resolved
    pub effects: Vec<Effect>,
}

/// Types of stack items
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Reflect)]
pub enum StackItemType {
    /// A spell being cast
    Spell,
    /// An activated ability
    ActivatedAbility,
    /// A triggered ability
    TriggeredAbility,
}

/// An effect that can be applied to the game state
#[derive(Debug, Clone, Serialize, Deserialize, Reflect)]
pub enum Effect {
    /// Deal damage to target(s)
    DealDamage {
        /// Amount of damage
        amount: u32,
        /// Source of the damage
        source: Entity,
    },
    /// Draw cards
    DrawCards {
        /// Number of cards to draw
        count: u32,
        /// Player who draws the cards
        player: Entity,
    },
    /// Add mana to a player's mana pool
    AddMana {
        /// Mana to add
        mana: Mana,
        /// Player to receive the mana
        player: Entity,
    },
    /// Destroy target permanent(s)
    DestroyPermanent {
        /// Whether the permanent can be regenerated
        can_regenerate: bool,
    },
    /// Exile target(s)
    Exile {
        /// Return zone if temporary
        return_zone: Option<ZoneType>,
        /// When to return (e.g., end of turn)
        return_condition: Option<ReturnCondition>,
    },
    // And many more effect types...
}
}

Game Actions

#![allow(unused)]
fn main() {
/// Actions that players can take
#[derive(Debug, Clone, Serialize, Deserialize, Reflect)]
pub enum PlayerAction {
    /// Play a land
    PlayLand {
        /// The land card to play
        card: Entity,
    },
    /// Cast a spell
    CastSpell {
        /// The spell to cast
        card: Entity,
        /// Targets for the spell
        targets: Vec<Entity>,
        /// Mana to spend
        mana: Mana,
    },
    /// Activate an ability
    ActivateAbility {
        /// Source of the ability
        source: Entity,
        /// Index of the ability on the source
        ability_index: usize,
        /// Targets for the ability
        targets: Vec<Entity>,
        /// Mana to spend
        mana: Option<Mana>,
    },
    /// Attack with creatures
    DeclareAttackers {
        /// Attacking creatures
        attackers: Vec<(Entity, Entity)>, // (Attacker, Defender)
    },
    /// Block with creatures
    DeclareBlockers {
        /// Blocking assignments
        blockers: Vec<(Entity, Entity)>, // (Blocker, Attacker)
    },
    /// Pass priority
    PassPriority,
    /// Mulligan
    Mulligan,
    /// Keep hand
    KeepHand,
    /// Concede game
    Concede,
}
}

Network Types

#![allow(unused)]
fn main() {
/// Network action to synchronize across clients
#[derive(Debug, Clone, Serialize, Deserialize, Reflect)]
pub struct NetworkAction {
    /// Player who initiated the action
    pub player_id: u64,
    /// The action taken
    pub action: PlayerAction,
    /// Timestamp for ordering
    pub timestamp: f64,
    /// Sequence number for this client
    pub sequence: u32,
}

/// Rollback information for network synchronization
#[derive(Debug, Clone, Serialize, Deserialize, Reflect)]
pub struct RollbackInfo {
    /// Snapshot ID to roll back to
    pub snapshot_id: Uuid,
    /// Reason for rollback
    pub reason: RollbackReason,
    /// New actions to apply after rollback
    pub actions: Vec<NetworkAction>,
}

/// Reasons for a rollback
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Reflect)]
pub enum RollbackReason {
    /// State desynchronization detected
    StateMismatch,
    /// Random number generator desynchronization
    RngMismatch,
    /// Network latency compensation
    LatencyCompensation,
    /// Action validation failure
    InvalidAction,
}
}

Event Types

#![allow(unused)]
fn main() {
/// Event triggered when a phase changes
#[derive(Event, Debug, Clone)]
pub struct PhaseChangeEvent {
    /// The new phase
    pub new_phase: Phase,
    /// The new step (if applicable)
    pub new_step: Option<Step>,
    /// The previous phase
    pub old_phase: Phase,
    /// The previous step (if applicable)
    pub old_step: Option<Step>,
}

/// Event triggered when damage is dealt
#[derive(Event, Debug, Clone)]
pub struct DamageEvent {
    /// Entity receiving damage
    pub target: Entity,
    /// Entity dealing damage
    pub source: Entity,
    /// Amount of damage
    pub amount: u32,
    /// Whether the damage is combat damage
    pub is_combat_damage: bool,
}

/// Event triggered when a card changes zones
#[derive(Event, Debug, Clone)]
pub struct ZoneChangeEvent {
    /// The card that changed zones
    pub card: Entity,
    /// The source zone
    pub from: ZoneType,
    /// The destination zone
    pub to: ZoneType,
    /// The entity that owns the destination zone
    pub to_owner: Entity,
    /// Position in the destination zone (if applicable)
    pub position: Option<usize>,
}

/// Event triggered when a snapshot should be taken
#[derive(Event, Debug, Clone)]
pub enum SnapshotEvent {
    /// Take a new snapshot
    Take,
    /// Apply a specific snapshot
    Apply(Uuid),
    /// Save the current state to disk
    Save(String),
    /// Load a state from disk
    Load(String),
}
}

Integration Types

#![allow(unused)]
fn main() {
/// Configuration for the game engine
#[derive(Resource, Debug, Clone, Serialize, Deserialize)]
pub struct GameConfig {
    /// Number of players
    pub player_count: usize,
    /// Starting life total
    pub starting_life: u32,
    /// Whether commander damage is enabled
    pub enable_commander_damage: bool,
    /// Starting hand size
    pub starting_hand_size: usize,
    /// Maximum hand size
    pub maximum_hand_size: usize,
    /// Number of cards to draw per turn
    pub draw_per_turn: usize,
    /// Random seed for deterministic gameplay
    pub random_seed: Option<u64>,
}

/// Configuration for snapshots
#[derive(Resource, Debug, Clone)]
pub struct SnapshotConfig {
    /// Whether to automatically take snapshots on turn change
    pub auto_snapshot_on_turn: bool,
    /// Whether to automatically take snapshots on phase change
    pub auto_snapshot_on_phase: bool,
    /// Maximum number of snapshots to process per frame
    pub max_snapshots_per_frame: usize,
    /// Compression level for snapshots (0-9)
    pub compression_level: u8,
}
}

Component Reference

This document provides a comprehensive reference of the various components used in Rummage to represent game entities and their properties.

Overview

Components in Bevy ECS are small, reusable pieces of data that are attached to entities. Rummage uses components to represent various aspects of Magic: The Gathering cards, players, and game elements.

Card Components

Components that represent aspects of Magic: The Gathering cards.

Core Card Components

#![allow(unused)]
fn main() {
/// The name of a card
#[derive(Component, Debug, Clone, Reflect, Serialize, Deserialize)]
#[reflect(Component)]
pub struct CardName {
    pub name: String,
}

/// The mana cost of a card
#[derive(Component, Debug, Clone, Reflect, Serialize, Deserialize)]
#[reflect(Component)]
pub struct CardCost {
    pub cost: Mana,
}

/// The type information of a card (creature, instant, etc.)
#[derive(Component, Debug, Clone, Reflect, Serialize, Deserialize)]
#[reflect(Component)]
pub struct CardTypeInfo {
    pub types: CardTypes,
}

/// The card's rules text
#[derive(Component, Debug, Clone, Reflect, Serialize, Deserialize)]
#[reflect(Component)]
pub struct CardRulesText {
    pub rules_text: String,
}

/// The specific details of a card (power/toughness for creatures, etc.)
#[derive(Component, Debug, Clone, Reflect, Serialize, Deserialize)]
#[reflect(Component)]
pub struct CardDetailsComponent {
    pub details: CardDetails,
}

/// The keyword abilities of a card
#[derive(Component, Debug, Clone, Reflect, Serialize, Deserialize)]
#[reflect(Component)]
pub struct CardKeywords {
    pub keywords: KeywordAbilities,
}
}

Card State Components

#![allow(unused)]
fn main() {
/// Indicates a tapped card
#[derive(Component, Debug, Clone, Reflect, Serialize, Deserialize)]
#[reflect(Component)]
pub struct Tapped;

/// Indicates a card with summoning sickness
#[derive(Component, Debug, Clone, Reflect, Serialize, Deserialize)]
#[reflect(Component)]
pub struct SummoningSickness;

/// Indicates a card that is attacking
#[derive(Component, Debug, Clone, Reflect, Serialize, Deserialize)]
#[reflect(Component)]
pub struct Attacker {
    pub attacking_player: Entity,
}

/// Indicates a card that is blocking
#[derive(Component, Debug, Clone, Reflect, Serialize, Deserialize)]
#[reflect(Component)]
pub struct Blocker {
    pub blocking: Entity,
}

/// Damage marked on a card
#[derive(Component, Debug, Clone, Reflect, Serialize, Deserialize)]
#[reflect(Component)]
pub struct DamageMarked {
    pub amount: u32,
}

/// Counters on a card
#[derive(Component, Debug, Clone, Reflect, Serialize, Deserialize)]
#[reflect(Component)]
pub struct Counters {
    pub counter_map: HashMap<CounterType, u32>,
}

/// Indicates a card is a token
#[derive(Component, Debug, Clone, Reflect, Serialize, Deserialize)]
#[reflect(Component)]
pub struct Token;
}

Zone Components

#![allow(unused)]
fn main() {
/// Indicates which game zone a card is in
#[derive(Component, Debug, Clone, Reflect, Serialize, Deserialize)]
#[reflect(Component)]
pub struct Zone {
    pub zone_type: ZoneType,
    pub owner: Entity,
    pub position: Option<usize>,
}

/// Represents a game zone (library, graveyard, etc.)
#[derive(Component, Debug, Clone, Reflect, Serialize, Deserialize)]
#[reflect(Component)]
pub struct ZoneContainer {
    pub zone_type: ZoneType,
    pub owner: Entity,
    pub contents: Vec<Entity>,
}
}

Player Components

Components that represent aspects of players.

Core Player Components

#![allow(unused)]
fn main() {
/// Core player component
#[derive(Component, Debug, Clone, Reflect, Serialize, Deserialize)]
#[reflect(Component)]
pub struct Player {
    pub id: usize,
    pub life_total: i32,
    pub is_active: bool,
}

/// Player's mana pool
#[derive(Component, Debug, Clone, Reflect, Serialize, Deserialize)]
#[reflect(Component)]
pub struct ManaPool {
    pub mana: Mana,
}

/// Player's hand size
#[derive(Component, Debug, Clone, Reflect, Serialize, Deserialize)]
#[reflect(Component)]
pub struct HandSize {
    pub current: usize,
    pub maximum: usize,
}
}

Commander-Specific Player Components

#![allow(unused)]
fn main() {
/// Commander damage received by a player
#[derive(Component, Debug, Clone, Reflect, Serialize, Deserialize)]
#[reflect(Component)]
pub struct CommanderDamage {
    pub damage_map: HashMap<Entity, u32>,
}

/// Commander color identity restrictions
#[derive(Component, Debug, Clone, Reflect, Serialize, Deserialize)]
#[reflect(Component)]
pub struct ColorIdentity {
    pub colors: HashSet<Color>,
}
}

Game State Components

Components that represent aspects of the game state.

Turn and Phase Components

#![allow(unused)]
fn main() {
/// Current game turn
#[derive(Resource, Debug, Clone, Reflect, Serialize, Deserialize)]
#[reflect(Resource)]
pub struct GameTurn {
    pub number: u32,
    pub active_player: Entity,
}

/// Current game phase
#[derive(Resource, Debug, Clone, Reflect, Serialize, Deserialize)]
#[reflect(Resource)]
pub struct GamePhase {
    pub phase: Phase,
    pub step: Option<Step>,
}

/// Priority holder
#[derive(Resource, Debug, Clone, Reflect, Serialize, Deserialize)]
#[reflect(Resource)]
pub struct Priority {
    pub player: Entity,
    pub passed_players: HashSet<Entity>,
}
}

Stack Components

#![allow(unused)]
fn main() {
/// The game stack (for spells and abilities)
#[derive(Resource, Debug, Clone, Reflect, Serialize, Deserialize)]
#[reflect(Resource)]
pub struct Stack {
    pub items: Vec<StackItem>,
}

/// An item on the stack
#[derive(Debug, Clone, Reflect, Serialize, Deserialize)]
pub struct StackItem {
    pub id: Uuid,
    pub source: Entity,
    pub controller: Entity,
    pub item_type: StackItemType,
    pub targets: Vec<Entity>,
    pub effects: Vec<Effect>,
}
}

UI Components

Components used for the user interface representation.

Card Visualization

#![allow(unused)]
fn main() {
/// Visual representation of a card
#[derive(Component, Debug, Clone)]
pub struct CardVisual {
    pub entity: Entity,
    pub card_face: Handle<Image>,
    pub is_facedown: bool,
    pub visual_state: CardVisualState,
}

/// UI states for cards
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum CardVisualState {
    Normal,
    Selected,
    Targeted,
    Highlighted,
    Disabled,
}

/// Interactive card component
#[derive(Component, Debug, Clone)]
pub struct Interactable {
    pub enabled: bool,
    pub interaction_type: InteractionType,
}
}

Layout Components

#![allow(unused)]
fn main() {
/// Battlefield position
#[derive(Component, Debug, Clone)]
pub struct BattlefieldPosition {
    pub row: usize,
    pub column: usize,
    pub rotation: f32,
}

/// Hand position
#[derive(Component, Debug, Clone)]
pub struct HandPosition {
    pub index: usize,
    pub total: usize,
}
}

Network Components

Components used for network synchronization.

#![allow(unused)]
fn main() {
/// Component for entities that should be synchronized over the network
#[derive(Component, Debug, Clone, Reflect, Serialize, Deserialize)]
#[reflect(Component)]
pub struct NetworkSynchronized {
    pub id: Uuid,
    pub version: u32,
    pub owner: Option<u64>, // Network ID of the owning client
}

/// Action performed by a player that needs network broadcasting
#[derive(Component, Debug, Clone, Reflect, Serialize, Deserialize)]
#[reflect(Component)]
pub struct PlayerAction {
    pub player: Entity,
    pub action_type: ActionType,
    pub targets: Vec<Entity>,
    pub timestamp: f64,
}
}

System Components

Internal components used by the game engine.

#![allow(unused)]
fn main() {
/// Marker for entities that should be included in snapshots
#[derive(Component, Debug, Clone)]
pub struct Snapshotable;

/// Temporary component for marking entities that need processing
#[derive(Component, Debug, Clone)]
pub struct NeedsProcessing;

/// Component for tracking when an entity was created
#[derive(Component, Debug, Clone, Reflect, Serialize, Deserialize)]
#[reflect(Component)]
pub struct Created {
    pub timestamp: f64,
    pub turn: u32,
}
}

Component Integration

Components are used together to represent complex game objects:

#![allow(unused)]
fn main() {
// Example of a full creature card entity with its components
commands.spawn((
    // Core card information
    Card::new(
        "Grizzly Bears",
        Mana::new(1, 0, 0, 0, 1, 0), // 1G
        CardTypes::new_creature(vec!["Bear".to_string()]),
        CardDetails::new_creature(2, 2),
        "", // No rules text
    ),
    // State information
    Zone {
        zone_type: ZoneType::Battlefield,
        owner: player_entity,
        position: None,
    },
    // Visual representation
    CardVisual {
        entity: Entity::PLACEHOLDER,
        card_face: card_image_handle,
        is_facedown: false,
        visual_state: CardVisualState::Normal,
    },
    // Battlefield positioning
    BattlefieldPosition {
        row: 0,
        column: 0,
        rotation: 0.0,
    },
    // System markers
    Snapshotable,
    NetworkSynchronized {
        id: Uuid::new_v4(),
        version: 0,
        owner: Some(player_network_id),
    },
));
}

Component Registration

Components must be registered with the Bevy type registry to be used with reflection, serialization, and UI:

#![allow(unused)]
fn main() {
fn register_components(app: &mut App) {
    app.register_type::<CardName>()
       .register_type::<CardCost>()
       .register_type::<CardTypeInfo>()
       .register_type::<CardRulesText>()
       .register_type::<CardDetailsComponent>()
       .register_type::<CardKeywords>()
       .register_type::<Tapped>()
       .register_type::<SummoningSickness>()
       .register_type::<Attacker>()
       .register_type::<Blocker>()
       .register_type::<DamageMarked>()
       .register_type::<Counters>()
       .register_type::<Token>()
       .register_type::<Zone>()
       .register_type::<ZoneContainer>()
       .register_type::<Player>()
       .register_type::<ManaPool>()
       .register_type::<HandSize>()
       .register_type::<CommanderDamage>()
       .register_type::<ColorIdentity>()
       .register_type::<NetworkSynchronized>()
       .register_type::<PlayerAction>()
       .register_type::<Created>();
} 
}

System Reference

This document provides a detailed reference of the various systems in Rummage that implement game logic, handle events, and manage game state.

Overview

Systems in Bevy ECS are the workhorses that process game logic. In Rummage, systems are organized into several major categories that map to Magic: The Gathering game concepts.

State Management Systems

State management systems handle the tracking and updating of game state.

Game State Systems

#![allow(unused)]
fn main() {
// Initialize the primary game state
fn init_game_state(mut commands: Commands) {
    commands.insert_resource(GameState {
        turn: 1,
        phase: Phase::Beginning,
        step: Step::Untap,
        active_player: 0,
        priority_player: 0,
        // ...
    });
}

// Update the game state based on input events
fn update_game_state(
    mut game_state: ResMut<GameState>,
    mut phase_events: EventReader<PhaseChangeEvent>,
    // ...
) {
    // Implementation...
}
}

Player State Systems

#![allow(unused)]
fn main() {
// Initialize player state
fn init_player_state(mut commands: Commands, game_config: Res<GameConfig>) {
    for player_idx in 0..game_config.player_count {
        commands.spawn((
            Player {
                id: player_idx,
                life_total: 40, // Commander starting life
                // ...
            },
            // Other components...
        ));
    }
}

// Update player state based on game events
fn update_player_state(
    mut player_query: Query<(&mut Player, &CommanderDamage)>,
    mut damage_events: EventReader<PlayerDamageEvent>,
    // ...
) {
    // Implementation...
}
}

Card State Systems

#![allow(unused)]
fn main() {
// Track state of cards on the battlefield
fn update_battlefield_cards(
    mut commands: Commands,
    mut card_query: Query<(Entity, &mut Card, &Zone)>,
    mut zone_change_events: EventReader<ZoneChangeEvent>,
    // ...
) {
    // Implementation...
}
}

Stack Systems

Systems that implement the MTG stack and priority mechanisms.

Stack Management

#![allow(unused)]
fn main() {
// Add objects to the stack
fn add_to_stack(
    mut commands: Commands,
    mut stack: ResMut<Stack>,
    mut stack_events: EventReader<AddToStackEvent>,
    // ...
) {
    // Implementation...
}

// Resolve objects from the stack
fn resolve_stack(
    mut commands: Commands,
    mut stack: ResMut<Stack>,
    mut game_state: ResMut<GameState>,
    // ...
) {
    // Implementation...
}
}

Priority Systems

#![allow(unused)]
fn main() {
// Handle passing of priority between players
fn handle_priority(
    mut game_state: ResMut<GameState>,
    mut priority_events: EventReader<PriorityPassedEvent>,
    // ...
) {
    // Implementation...
}
}

Combat Systems

Systems that implement the combat phase and damage resolution.

Combat Flow

#![allow(unused)]
fn main() {
// Start the combat phase
fn start_combat(
    mut commands: Commands,
    mut game_state: ResMut<GameState>,
    mut phase_events: EventWriter<PhaseChangeEvent>,
    // ...
) {
    // Implementation...
}

// Handle the declare attackers step
fn declare_attackers(
    mut commands: Commands,
    mut game_state: ResMut<GameState>,
    mut attacker_query: Query<(Entity, &Card), With<Attacker>>,
    // ...
) {
    // Implementation...
}

// Handle the declare blockers step
fn declare_blockers(
    mut commands: Commands,
    mut game_state: ResMut<GameState>,
    mut blocker_query: Query<(Entity, &Card), With<Blocker>>,
    // ...
) {
    // Implementation...
}

// Combat damage calculation and assignment
fn combat_damage(
    mut commands: Commands,
    mut attacker_query: Query<(Entity, &Attacker, &Card)>,
    mut blocker_query: Query<(Entity, &Blocker, &Card)>,
    mut damage_events: EventWriter<DamageEvent>,
    // ...
) {
    // Implementation...
}
}

Combat Damage

#![allow(unused)]
fn main() {
// Apply combat damage to creatures and players
fn apply_combat_damage(
    mut commands: Commands,
    mut creature_query: Query<(Entity, &mut Card)>,
    mut player_query: Query<(Entity, &mut Player)>,
    mut damage_events: EventReader<DamageEvent>,
    // ...
) {
    // Implementation...
}
}

Turn Systems

Systems that handle turn structure and phase progression.

Turn Progression

#![allow(unused)]
fn main() {
// Start a new turn
fn start_turn(
    mut commands: Commands,
    mut game_state: ResMut<GameState>,
    mut turn_events: EventWriter<TurnStartEvent>,
    // ...
) {
    // Implementation...
}

// Transition between phases
fn change_phase(
    mut game_state: ResMut<GameState>,
    mut phase_events: EventReader<PhaseChangeEvent>,
    // ...
) {
    // Implementation...
}

// End the current turn
fn end_turn(
    mut commands: Commands,
    mut game_state: ResMut<GameState>,
    mut turn_events: EventWriter<TurnEndEvent>,
    // ...
) {
    // Implementation...
}
}

Commander Systems

Systems specific to the Commander format.

Commander Tax

#![allow(unused)]
fn main() {
// Track and apply commander tax
fn apply_commander_tax(
    mut commands: Commands,
    mut commander_query: Query<(Entity, &mut CommanderCard)>,
    mut cast_events: EventReader<CommanderCastEvent>,
    // ...
) {
    // Implementation...
}
}

Commander Damage

#![allow(unused)]
fn main() {
// Track commander damage between players
fn track_commander_damage(
    mut commands: Commands,
    mut player_query: Query<(Entity, &mut CommanderDamage)>,
    mut damage_events: EventReader<CommanderDamageEvent>,
    // ...
) {
    // Implementation...
}
}

Event Systems

Systems that process various game events.

Event Dispatch

#![allow(unused)]
fn main() {
// Main event dispatcher system
fn dispatch_events(
    mut commands: Commands,
    mut card_played_events: EventReader<CardPlayedEvent>,
    mut zone_change_events: EventReader<ZoneChangeEvent>,
    // Other event readers...
    mut card_query: Query<(Entity, &mut Card)>,
    // ...
) {
    // Implementation...
}
}

Event Processing

#![allow(unused)]
fn main() {
// Process various types of events
fn process_card_played(
    mut commands: Commands,
    mut card_played_events: EventReader<CardPlayedEvent>,
    // ...
) {
    // Implementation...
}

fn process_zone_changes(
    mut commands: Commands,
    mut zone_change_events: EventReader<ZoneChangeEvent>,
    // ...
) {
    // Implementation...
}
}

Snapshot Systems

Systems for capturing and restoring game state.

Snapshot Creation

#![allow(unused)]
fn main() {
// Create a game state snapshot
fn create_snapshot(
    world: &World, 
    game_state: Res<GameState>,
    mut snapshot_events: EventWriter<SnapshotEvent>,
    // ...
) {
    // Implementation...
}
}

Snapshot Restoration

#![allow(unused)]
fn main() {
// Restore from a snapshot
fn apply_snapshot(
    mut commands: Commands,
    snapshot: Res<GameSnapshot>,
    // ...
) {
    // Implementation...
}
}

UI Integration Systems

Systems that connect game logic to the user interface.

UI Update Systems

#![allow(unused)]
fn main() {
// Update UI elements based on game state
fn update_battlefield_ui(
    mut commands: Commands,
    card_query: Query<(Entity, &Card, &Zone)>,
    mut ui_query: Query<(Entity, &mut UiTransform, &CardUi)>,
    // ...
) {
    // Implementation...
}

// Handle user input events
fn handle_card_interaction(
    mut commands: Commands,
    mut interaction_events: EventReader<CardInteractionEvent>,
    card_query: Query<(Entity, &Card)>,
    // ...
) {
    // Implementation...
}
}

Network Integration Systems

Systems that handle network synchronization.

State Synchronization

#![allow(unused)]
fn main() {
// Synchronize game state across the network
fn sync_game_state(
    mut commands: Commands,
    game_state: Res<GameState>,
    mut replicon: ResMut<Replicon>,
    // ...
) {
    // Implementation...
}
}

Action Broadcasting

#![allow(unused)]
fn main() {
// Broadcast player actions to all clients
fn broadcast_actions(
    mut commands: Commands,
    mut action_events: EventReader<PlayerActionEvent>,
    mut replicon: ResMut<Replicon>,
    // ...
) {
    // Implementation...
}
}

System Registration

Systems are registered with the Bevy App in the following manner:

#![allow(unused)]
fn main() {
// Register all game systems
fn build_game_systems(app: &mut App) {
    app
        // Core game state systems
        .add_systems(Startup, init_game_state)
        .add_systems(Update, update_game_state)
        
        // Turn and phase systems
        .add_systems(Update, (
            start_turn,
            change_phase,
            end_turn,
        ).chain())
        
        // Combat systems
        .add_systems(Update, (
            start_combat,
            declare_attackers,
            declare_blockers,
            combat_damage,
            apply_combat_damage,
        ).chain().run_if(in_combat_phase))
        
        // Stack systems
        .add_systems(Update, (
            add_to_stack,
            resolve_stack,
            handle_priority,
        ).chain())
        
        // Event systems
        .add_systems(Update, dispatch_events)
        .add_systems(Update, (
            process_card_played,
            process_zone_changes,
        ))
        
        // Commander-specific systems
        .add_systems(Update, (
            apply_commander_tax,
            track_commander_damage,
        ))
        
        // Snapshot systems
        .add_systems(Update, (
            create_snapshot.run_if(resource_exists::<SnapshotConfig>()),
            apply_snapshot.run_if(resource_exists::<PendingSnapshots>()),
        ))
        
        // UI integration systems
        .add_systems(Update, (
            update_battlefield_ui,
            handle_card_interaction,
        ))
        
        // Network integration systems
        .add_systems(Update, (
            sync_game_state,
            broadcast_actions,
        ).run_if(resource_exists::<Replicon>()));
}
}

Magic: The Gathering Rules Reference

This section provides a comprehensive reference to the Magic: The Gathering rules as implemented in Rummage. It serves as a bridge between the official comprehensive rules and our game engine implementation.

How Rules Are Organized

The Rummage documentation organizes Magic: The Gathering rules into three layers:

  1. MTG Rules Reference (this section) - A high-level explanation of the rules and mechanics with links to implementation details
  2. MTG Core Rules - Detailed implementation of fundamental rules shared by all formats
  3. Format-Specific Rules - Extensions and modifications for specific formats (e.g., Commander)

This layered approach ensures that common rules are documented once in the core layer, while format-specific variations are documented in their respective format sections.

Core Rules vs. Format Rules

Understanding the distinction between core rules and format rules is essential:

  • Core Rules: Universal mechanics that apply to all Magic: The Gathering games (turn structure, stack, zones, etc.)
  • Format Rules: Additional rules and modifications specific to a format (Commander damage, partner commanders, etc.)

In Rummage, both are implemented as composable ECS systems, allowing shared core systems with format-specific extensions.

Implementation Methodology

Our rules implementation follows a methodology designed for correctness, testability, and extensibility:

  1. Rule Extraction: Rules are extracted from the Comprehensive Rules
  2. System Design: Rules are modeled as composable Bevy ECS systems
  3. State Representation: Game state is represented as entities with components
  4. Event-Driven Logic: Rules are triggered by and produce game events
  5. Deterministic Execution: Rules execute deterministically for network play

Integration with Core Systems

The rules implementation integrates with several core systems:

Snapshot System

The Snapshot System works closely with the rules implementation to:

  • Capture game state at specific points in the turn structure
  • Ensure deterministic rule application for networked games
  • Enable replay and analysis of rule applications
  • Support testing of complex rule interactions

For more information on how snapshots are used in testing rule implementations, see Snapshot Testing.

Rules Categories

The MTG rules are broken down into the following main categories:

Game Structure Rules

Card Rules

  • Card Types - The various types of cards and their characteristics
  • Card States - Different states a card can be in (tapped, face-down, etc.)
  • Mana Costs - How mana costs work and are calculated

Gameplay Rules

  • Combat (Implementation) - Rules for attacking, blocking, and combat damage
  • Targeting - How targets are selected and validated
  • Effects - Different types of effects and how they're applied
  • Keywords - Standard keyword abilities and their implementations

Advanced Rules

Format-Specific Rules

This reference provides high-level explanations of format-specific rules. For detailed implementation details, refer to the format-specific documentation:

Note: Currently, only the Commander format is fully documented. Additional formats like Two-Headed Giant and Planechase may be added in the future.

Implementation Examples

Throughout the rules documentation, you'll find code examples showing how the rules are implemented in Rummage:

#![allow(unused)]
fn main() {
// Example: A system implementing state-based actions
pub fn check_state_based_actions(
    mut commands: Commands,
    mut creatures: Query<(Entity, &Creature, &Health)>,
    mut players: Query<(Entity, &Player)>,
    mut game_events: EventWriter<GameEvent>,
) {
    // Check for creatures with lethal damage
    for (entity, creature, health) in creatures.iter() {
        if health.damage >= creature.toughness {
            // Creature has lethal damage, destroy it
            commands.entity(entity).insert(Destroyed);
            game_events.send(GameEvent::CreatureDestroyed { entity });
        }
    }
    
    // Check for players with zero or less life
    for (entity, player) in players.iter() {
        if player.life <= 0 {
            game_events.send(GameEvent::PlayerLost { 
                player: entity,
                reason: LossReason::ZeroLife,
            });
        }
    }
    
    // Other state-based actions...
}
}

Rules Interactions

Magic: The Gathering is known for its complex rule interactions. The documentation explains how different rule systems interact:

  • How the stack interacts with state-based actions
  • How replacement effects modify zone changes
  • How continuous effects are applied in layers
  • How priority flows during complex game scenarios

Testing Rules Correctness

The Rummage engine extensively tests rules correctness:

  • Unit tests for individual rule applications
  • Integration tests for interactions between rule systems
  • Scenario tests for complex game states
  • Regression tests for previously identified issues

For more details on how rules are tested, see the Testing Overview.

Rules Implementation Resources

How to Use This Documentation

  • For a high-level overview of a rule, start with the relevant page in this MTG Rules section
  • For implementation details, follow the links to the MTG Core Rules section
  • For format-specific rules, check the format's dedicated rules documentation
  • For code examples, look at the implementation snippets provided throughout

Next: Comprehensive Rules Overview

Magic: The Gathering Comprehensive Rules

This page provides an overview of the Magic: The Gathering Comprehensive Rules and how they are implemented in the Rummage game engine.

Introduction

The Magic: The Gathering Comprehensive Rules are the complete ruleset for the game, covering all possible interactions and edge cases. The current version used in Rummage is dated February 7, 2025, and can be found in MagicCompRules 20250207.txt.

Implementation Approach

Rummage implements the Comprehensive Rules through a modular system of components and systems. Each section of the rules is mapped to specific game logic:

  1. Game Concepts (Rules 100-199)

    • Core game logic, turn structure, and win conditions
    • Starting the game, ending the game
    • Colors, mana, and basic card types
  2. Parts of a Card (Rules 200-299)

    • Card data structures
    • Card characteristics and attributes
    • Card types and subtypes
  3. Card Types (Rules 300-399)

    • Type-specific behaviors (creatures, artifacts, etc.)
    • Type-changing effects
    • Supertype rules (legendary, basic, etc.)
  4. Zones (Rules 400-499)

    • Zone implementation
    • Movement between zones
    • Zone-specific rules
  5. Turn Structure (Rules 500-599)

    • Phase and step management
    • Beginning, combat, and ending phases
    • Extra turns and additional phases
  6. Spells, Abilities, and Effects (Rules 600-699)

    • Spell casting
    • Ability implementation
    • Effect resolution
  7. Additional Rules (Rules 700-799)

    • Actions and special actions
    • State-based actions
    • Commander-specific rules

Testing Approach

Each section of the Comprehensive Rules is covered by specific test cases to ensure compliance:

  • Rule-Specific Tests: Each rule with significant game impact has dedicated unit tests
  • Interaction Tests: Tests for complex interactions between different rules
  • Edge-Case Tests: Tests for unusual rule applications and corner cases
  • Oracle Rulings: Tests based on official Wizards of the Coast rulings

Key Implementation Challenges

  1. Rule Interdependencies: Many rules reference or depend on other rules, requiring careful implementation order
  2. State-Based Actions: Continuous checking of game state conditions (rule 704)
  3. Layering System: Implementation of continuous effects in the correct order (rule 613)
  4. Replacement Effects: Handling multiple replacement effects that could apply to the same event (rule 616)

Implementation Status

The table below summarizes the implementation status of major rule sections:

Rule SectionDescriptionStatusNotes
100-199Game ConceptsCore game flow implemented
200-299Parts of a CardCard model complete
300-309Card TypesAll card types supported
400-499ZonesAll zones implemented
500-599Turn StructureComplete turn sequence
600-609SpellsSpell casting fully supported
610-613Effects🔄Complex continuous effects in progress
614-616Replacement Effects🔄Being implemented
700-799Additional Rules🔄Specialized rules in development

Legend:

  • ✅ Implemented and tested
  • 🔄 In progress
  • ⚠️ Planned but not yet implemented

Example Rule Implementation

Here's a simplified example of how a rule is implemented in the Rummage codebase:

#![allow(unused)]
fn main() {
// Implementing Rule 302.6: "A creature's activated ability with the tap symbol 
// in its activation cost can't be activated unless the creature has been under 
// its controller's control continuously since their most recent turn began."
pub fn can_use_tap_ability(
    creature: &Creature,
    game_state: &GameState
) -> bool {
    // Check if creature has summoning sickness
    if !creature.has_haste && creature.turns_under_current_control < 1 {
        return false;
    }
    // More checks as needed...
    true
}
}

References

Commander-Specific Rules

This page provides a high-level reference for the official Commander format rules. For detailed implementation specifics, see the Commander Format documentation.

What Is Commander?

Commander (formerly known as Elder Dragon Highlander or EDH) is a multiplayer format for Magic: The Gathering created by players and embraced by Wizards of the Coast as an official format. It emphasizes social gameplay and creative deck building around a chosen legendary creature.

Official Commander Rules

The Commander format is governed by a specific set of rules maintained by the Commander Rules Committee:

Deck Construction Rules

  • Players choose a legendary creature as their "commander"
  • A deck contains exactly 100 cards, including the commander
  • Except for basic lands, no two cards in the deck may have the same English name
  • A card's color identity must be within the color identity of the commander
  • Color identity includes colored mana symbols in costs and rules text

Game Play Rules

  • Players begin with 40 life
  • Commanders begin the game in the "command zone"
  • While a commander is in the command zone, it may be cast, subject to normal timing restrictions
  • Each time a player casts their commander from the command zone, it costs an additional {2} for each previous time they've cast it
  • If a commander would be exiled or put into a hand, graveyard, or library, its owner may choose to move it to the command zone instead
  • A player that has been dealt 21 or more combat damage by the same commander loses the game

Rule Implementation References

For detailed implementation of these rules in Rummage, refer to these sections:

Commander Variants

Rummage supports these common Commander variants:

Partner Commanders

Some legendary creatures have the "Partner" ability, allowing a deck to have two commanders. See Partner Commanders for implementation details.

Commander Ninjutsu

A special ability that allows commanders to be put onto the battlefield from the command zone. See Commander Ninjutsu for implementation details.

Brawl

A variant with 60-card decks, only using Standard-legal cards and starting at 25 life.

Commander Death Triggers

Special rules for how commander death and zone changes work. See Commander Death Triggers for implementation details.

Commander-Specific Cards

Many cards have been designed specifically for the Commander format. See Commander-Specific Cards for details on how these cards are implemented.

References

Turn Structure

This document provides an overview of the turn structure rules in Magic: The Gathering. For the detailed implementation in Rummage, please see Turn Structure Implementation.

Overview of Turn Structure

A turn in Magic: The Gathering consists of five phases, some of which are divided into steps. The phases proceed in the following order:

  1. Beginning Phase

    • Untap Step
    • Upkeep Step
    • Draw Step
  2. First Main Phase

  3. Combat Phase

    • Beginning of Combat Step
    • Declare Attackers Step
    • Declare Blockers Step
    • Combat Damage Step
    • End of Combat Step
  4. Second Main Phase

  5. Ending Phase

    • End Step
    • Cleanup Step

Phase and Step Rules

Phase transitions follow these rules:

  1. Each phase or step begins with game events that happen automatically
  2. Then, the active player receives priority
  3. When all players pass priority in succession with an empty stack, the phase or step ends
  4. The game proceeds to the next phase or step

Special Turn Structure Rules

Priority

  • Players receive priority in APNAP (Active Player, Non-Active Player) order
  • No player receives priority during the untap step or cleanup step (unless a trigger occurs)
  • The active player receives priority first in each phase/step

Skipping Steps

  • If no player has a triggered ability triggering at the beginning of a step and no player takes an action during that step, that step is skipped
  • The upkeep step, draw step, and end step are never skipped this way

Combat Phase

The combat phase has special rules:

  • Only creatures can attack
  • Creatures with summoning sickness cannot attack
  • The active player chooses which creatures attack and which player or planeswalker they attack
  • The defending player(s) choose which creatures block and how blocking is assigned

Format-Specific Variations

Different formats may have specific variations in how turns proceed:

  • Commander: See Commander Turn Structure for multiplayer turn order rules
  • Two-Headed Giant: Teams share turns and certain steps
  • Planechase: Additional actions may be taken during main phases

For detailed information about turn structure in Rummage, please see:

For format-specific implementations:

The Stack

This document provides an overview of the stack rules in Magic: The Gathering. For the detailed implementation in Rummage, please see Stack and Priority System.

What is the Stack?

The stack is a zone in Magic: The Gathering where spells and abilities exist while waiting to resolve. It uses the "last in, first out" (LIFO) principle - the most recently added object on the stack is the first to resolve.

Stack Rules

Adding to the Stack

The following objects use the stack:

  1. Spells: When a player casts a spell, it's put on the stack.
  2. Activated Abilities: When a player activates an ability, it's put on the stack.
  3. Triggered Abilities: When a triggered ability triggers, it's put on the stack the next time a player would receive priority.

Resolution Process

  1. Each player, in turn order starting with the active player, receives priority.
  2. A player with priority may:
    • Cast a spell
    • Activate an ability
    • Pass priority
  3. When all players pass priority in succession:
    • If the stack is empty, the current phase or step ends.
    • If the stack has objects, the topmost object resolves.

Resolving Stack Objects

When a spell or ability resolves:

  1. Its instructions are followed in order.
  2. If it's a permanent spell, it enters the battlefield.
  3. It leaves the stack and ceases to exist (unless it's a permanent spell).

Special Stack Rules

  • Split second: Spells with split second prevent players from casting spells or activating abilities while they're on the stack (except for mana abilities and certain special actions).
  • Counterspells: These spells target other spells on the stack and prevent them from resolving.
  • Mana abilities: These abilities don't use the stack and resolve immediately.
  • Special actions: Certain game actions don't use the stack (e.g., playing a land).

For the detailed implementation of the stack in Rummage, including code examples and integration with other systems, see:

Game Zones

This document provides an overview of game zones in Magic: The Gathering. For the detailed implementation in Rummage, please see Zones Implementation.

Overview

Magic: The Gathering divides the game into distinct areas called zones. Each zone has specific rules governing how cards can enter, leave, and interact while in that zone.

Standard Game Zones

The standard game includes the following zones:

Library

  • The player's deck of cards
  • Cards are face-down and in a random order
  • Players draw from the top of their library
  • Running out of cards in the library can cause a player to lose the game

Hand

  • Cards held by a player
  • Normally hidden from opponents
  • Maximum hand size (usually seven) is enforced during the cleanup step
  • Cards in hand can be cast or played according to their type and timing restrictions

Battlefield

  • Permanents (lands, creatures, artifacts, enchantments, planeswalkers) exist on the battlefield
  • Cards on the battlefield are typically face-up (exceptions exist for morphed/manifested cards)
  • Cards on the battlefield can tap, untap, attack, block, and be affected by other cards
  • Positioning on the battlefield can matter for certain cards and effects

Graveyard

  • Discard pile for destroyed, sacrificed, or discarded cards
  • Cards are face-up and can be examined by any player
  • Order of cards in the graveyard matters for some effects
  • Some abilities allow cards to be played or returned from the graveyard

Stack

  • Holds spells and abilities that have been cast or activated but haven't resolved yet
  • Operates as a last-in, first-out (LIFO) data structure
  • Spells and abilities resolve one at a time from top to bottom
  • Players can respond to objects on the stack by adding more spells or abilities

Exile

  • Cards removed from the game
  • Cards are typically face-up unless specified otherwise
  • Generally, cards in exile cannot interact with the game
  • Some cards can return cards from exile or interact with exiled cards

Command

  • Special zone for format-specific cards or game elements
  • In Commander, this zone holds commander cards
  • In other formats, it may hold emblems, planes, schemes, etc.
  • Cards in the command zone have special rules for how they can be cast or used

Zone Transitions

Cards can move between zones according to specific rules:

  1. Casting a Spell: Card moves from hand to the stack
  2. Spell Resolution: Card moves from stack to battlefield (permanents) or graveyard (instants/sorceries)
  3. Destroying/Sacrificing: Permanent moves from battlefield to graveyard
  4. Drawing: Card moves from library to hand
  5. Discarding: Card moves from hand to graveyard
  6. Exiling: Card moves from any zone to exile

Zone Change Rules

When a card changes zones:

  • It becomes a new object with no memory of its previous existence
  • Counters, attachments, and continuous effects that previously affected it no longer apply
  • Exceptions exist for abilities that specifically track the object across zone changes

Format-Specific Zone Rules

Different formats may modify zone rules:

  • Commander: The command zone holds commander cards, which can be cast from there
  • Planechase: The planar deck and active plane exist in the command zone
  • Archenemy: Scheme cards reside in the command zone

For more information about zones in Rummage, see:

Combat

This document provides an overview of the combat rules in Magic: The Gathering. For the detailed implementation in Rummage, please see Combat System.

Combat Phase Overview

The combat phase consists of five steps:

  1. Beginning of Combat Step
  2. Declare Attackers Step
  3. Declare Blockers Step
  4. Combat Damage Step
  5. End of Combat Step

Combat Flow

The flow of combat follows these steps:

Beginning of Combat Step

  • The active player receives priority
  • Players can cast instants and activate abilities
  • No combat actions yet occur

Declare Attackers Step

  • The active player declares which of their creatures are attacking and what player or planeswalker each is attacking
  • Tapped creatures and creatures with summoning sickness (that don't have haste) cannot attack
  • Once attackers are declared, triggered abilities trigger and the active player receives priority

Declare Blockers Step

  • The defending player(s) declare which of their untapped creatures are blocking and which attacker each is blocking
  • A single creature can block only one attacker unless it has the ability to block multiple attackers
  • Multiple creatures can block a single attacker
  • After blockers are declared, the active player assigns the combat damage order for each attacking creature that's blocked by multiple creatures
  • Triggered abilities trigger and the active player receives priority

Combat Damage Step

  • Combat damage is assigned and dealt simultaneously by all attacking and blocking creatures
  • Creatures with first strike or double strike deal damage in a separate combat damage step before creatures without first strike
  • Creatures with trample can assign excess damage to the player or planeswalker they're attacking
  • After damage is dealt, triggered abilities trigger and the active player receives priority

End of Combat Step

  • Final opportunity to use "until end of combat" effects
  • Triggered abilities trigger and the active player receives priority

Special Combat Rules

First Strike and Double Strike

  • Creatures with first strike deal combat damage before creatures without first strike
  • Creatures with double strike deal combat damage twice, once during the first strike damage step and once during the regular damage step
  • If any creature has first strike or double strike, there are two combat damage steps

Trample

  • If all creatures blocking an attacking creature with trample are assigned lethal damage, excess damage can be assigned to the player or planeswalker that creature is attacking

Evasion Abilities

  • Flying: Can only be blocked by creatures with flying or reach
  • Fear: Can only be blocked by artifact creatures and/or black creatures
  • Intimidate: Can only be blocked by artifact creatures and/or creatures that share a color with it
  • Menace: Can't be blocked except by two or more creatures
  • Shadow: Can only block or be blocked by creatures with shadow

Combat Keywords

  • Vigilance: Creature doesn't tap when attacking
  • Deathtouch: Any amount of damage dealt by a creature with deathtouch is considered lethal
  • Lifelink: Damage dealt by a creature with lifelink causes its controller to gain that much life
  • Indestructible: Creature can't be destroyed by damage or "destroy" effects

For the detailed implementation of combat in Rummage, including code examples and integration with other systems, see:

State-Based Actions

This document provides an overview of state-based actions in Magic: The Gathering. For the detailed implementation in Rummage, please see State-Based Actions.

What Are State-Based Actions?

State-based actions are game rules that are continuously checked and automatically applied whenever a player would receive priority. They handle common game situations without requiring explicit triggers.

Key State-Based Actions

The most common state-based actions include:

  • A creature with toughness ≤ 0 is put into its owner's graveyard
  • A creature with lethal damage marked on it is destroyed
  • A creature that has been dealt damage by a source with deathtouch is destroyed
  • A creature with toughness greater than 0 and damage marked on it equal to or greater than its toughness has lethal damage
  • A player with 0 or less life loses the game
  • A player who attempted to draw from an empty library loses the game
  • A player with 10 or more poison counters loses the game
  • A player who has attempted to draw more cards than their library contains loses the game
  • If two or more legendary permanents with the same name are on the battlefield, their owners choose one to keep and put the rest into their owners' graveyards
  • If two or more planeswalkers with the same planeswalker type are on the battlefield, their controllers choose one to keep and put the rest into their owners' graveyards
  • An Aura attached to an illegal object or player, or not attached to an object or player, is put into its owner's graveyard
  • An Equipment or Fortification attached to an illegal permanent becomes unattached
  • A token that has left the battlefield ceases to exist
  • A copy of a spell in a zone other than the stack ceases to exist

When State-Based Actions Are Checked

State-based actions are checked:

  1. Whenever a player would receive priority
  2. After a spell or ability resolves
  3. After combat damage is dealt
  4. During cleanup step

They are NOT checked during the resolution of a spell or ability, or during the process of casting a spell or activating an ability.

Multiple State-Based Actions

If multiple state-based actions would apply simultaneously:

  1. All applicable state-based actions are performed simultaneously
  2. The system then checks again to see if any new state-based actions need to be performed
  3. This process repeats until no more state-based actions are applicable

Implementation Note

For the detailed implementation of state-based actions in Rummage, including code examples and integration with other systems, see:

MTG Targeting Rules

This document outlines the rules for targeting in Magic: The Gathering as implemented in Rummage.

Core Targeting Rules

According to the Magic: The Gathering Comprehensive Rules:

  1. The term "target" is used to describe an object that a spell or ability will affect.
  2. An object that requires targets is put on the stack with those targets already chosen.
  3. Targets are always declared as part of casting a spell or activating an ability.
  4. A target must be valid both when declared and when the spell or ability resolves.

Valid Targets

A valid target must:

  1. Meet any specific requirements of the targeting effect
  2. Be in the appropriate zone (usually on the battlefield)
  3. Not have hexproof or shroud (relative to the controller of the targeting effect)
  4. Not have protection from the relevant quality of the targeting effect

Implementation

In Rummage, targeting is implemented through several components:

#![allow(unused)]
fn main() {
pub enum TargetType {
    Player,
    Creature,
    Permanent,
    AnyTarget, // Player or creature
    // ...other target types
}

pub struct TargetRequirement {
    pub target_type: TargetType,
    pub count: TargetCount,
    pub additional_requirements: Vec<Box<dyn TargetFilter>>,
}
}

Targeting Phases

The targeting process has several phases:

  1. Declaration: The player declares targets for a spell or ability
  2. Validation: The system validates that the targets are legal
  3. Resolution: When the spell or ability resolves, targets are checked again
  4. Effect Application: The effect is applied to valid targets

Illegal Targets

If a target becomes illegal before a spell or ability resolves:

  1. The spell or ability will still resolve
  2. The effect will not be applied to the illegal target
  3. If all targets are illegal, the spell or ability is countered by game rules

UI Integration

The targeting rules integrate with the UI through:

Examples

Single Target Spell

#![allow(unused)]
fn main() {
// Lightning Bolt implementation
let lightning_bolt = Card::new("Lightning Bolt")
    .with_cost(Mana::new(0, 1, 0, 0, 0, 0))
    .with_target_requirement(TargetRequirement {
        target_type: TargetType::AnyTarget,
        count: TargetCount::Exactly(1),
        additional_requirements: vec![],
    })
    .with_effect(|game, targets| {
        for target in targets {
            game.deal_damage(target, 3, DamageType::Spell);
        }
    });
}

Multiple Targets Spell

#![allow(unused)]
fn main() {
// Electrolyze implementation
let electrolyze = Card::new("Electrolyze")
    .with_cost(Mana::new(0, 1, 0, 1, 0, 0))
    .with_target_requirement(TargetRequirement {
        target_type: TargetType::AnyTarget,
        count: TargetCount::UpTo(2),
        additional_requirements: vec![],
    })
    .with_effect(|game, targets| {
        for target in targets {
            game.deal_damage(target, 1, DamageType::Spell);
        }
        game.draw_cards(game.active_player, 1);
    });
}

Card States in MTG

This document describes the various states that cards can have according to the Magic: The Gathering rules, and how these states are implemented in Rummage.

Core Card States

According to the Magic: The Gathering rules, cards can have the following states:

Tapped vs. Untapped

  • Tapped: A card that has been turned sideways to indicate it has been used
  • Untapped: A card in its normal vertical orientation

Face-up vs. Face-down

  • Face-up: A card's face is visible to all players
  • Face-down: A card's face is hidden from all players (with certain exceptions)

Flipped vs. Unflipped

  • Flipped: A card that has been turned 180 degrees
  • Unflipped: A card in its normal orientation

Special States

In addition to the core states, cards can have several special states:

Phased In/Out

  • Phased In: Normal state, the card exists in the game
  • Phased Out: Card is treated as though it doesn't exist

Transformed

  • For double-faced cards, the state of which face is currently showing

Meld

  • When certain cards are combined into a single larger card

State Tracking

In Rummage, card states are tracked using components:

#![allow(unused)]
fn main() {
pub struct CardState {
    pub tapped: bool,
    pub face_down: bool,
    pub flipped: bool,
    pub phased_out: bool,
    pub transformed: bool,
    // Other states
}
}

Rules for State Changes

State changes follow specific rules:

  1. Tapping: Usually happens as a cost or an effect
  2. Untapping: Normally happens during the untap step
  3. Face-down: Usually through effects like Morph
  4. Flipping: Only happens through specific card effects

State Interaction with Game Rules

Card states interact with game rules in various ways:

  • Tapped creatures can't attack or use tap abilities
  • Face-down creatures are 2/2 creatures with no text, name, or types
  • Phased-out cards are treated as though they don't exist

Visual Representation

For details on how these states are visually represented in the game UI, see Card States Visualization.

Mana and Costs

This document provides an overview of the rules for mana and costs in Magic: The Gathering. For implementation details in Rummage, see the relevant code in the mana.rs file.

Mana Types

Magic: The Gathering has six types of mana:

  1. White (W)
  2. Blue (U)
  3. Black (B)
  4. Red (R)
  5. Green (G)
  6. Colorless (C)

Additionally, there is the concept of generic mana, represented by numbers (e.g., {1}, {2}), which can be paid with any type of mana.

Mana Costs

A spell's mana cost appears in the upper right corner of the card and consists of mana symbols. For example:

  • {2}{U} means "two generic mana and one blue mana"
  • {W}{W} means "two white mana"
  • {X}{R}{R} means "X generic mana and two red mana" where X is chosen by the player

Mana Pool

Players have a mana pool where mana is stored until spent or until a phase or step ends. Mana in a player's mana pool can be spent to pay costs or may be lost when a phase or step ends.

Mana Sources

Mana is produced by mana abilities, which come from various sources:

  • Land abilities: Basic lands and many nonbasic lands
  • Creature abilities: Mana dorks and other creatures
  • Artifact abilities: Mana rocks and other artifacts
  • Enchantment abilities: Various enchantments
  • Spells: Some instants and sorceries produce mana

Additional Costs

Many spells and abilities have additional costs beyond their mana cost:

  • Tap costs: {T} means "tap this permanent"
  • Life costs: "Pay N life"
  • Sacrifice costs: "Sacrifice a creature"
  • Discard costs: "Discard a card"
  • Exile costs: "Exile a card from your graveyard"

Alternative Costs

Some spells can be cast for alternative costs:

  • Flashback: Cast from graveyard for a different cost
  • Overload: Cast with a different effect for a different cost
  • Foretell: Cast from exile for a different cost
  • Evoke: Cast with a sacrifice trigger for a reduced cost

Cost Reduction

Various effects can reduce costs:

  • "Spells you cast cost {1} less to cast"
  • "This spell costs {1} less to cast for each artifact you control"
  • "The first creature spell you cast each turn costs {2} less to cast"

Cost Increases

Similarly, effects can increase costs:

  • "Spells your opponents cast cost {1} more to cast"
  • "Activated abilities cost {2} more to activate"
  • "Creature spells with flying cost {1} more to cast"

Special Mana Types

Beyond the basic types, there are special types of mana:

  • Snow mana ({S}): Produced by snow permanents
  • Phyrexian mana ({W/P}, {U/P}, etc.): Can be paid with either colored mana or 2 life
  • Hybrid mana ({W/U}, {B/R}, etc.): Can be paid with either of two colors
  • Colorless-specific mana ({C}): Must be paid with colorless mana, not any color

Mana Conversion

Mana conversion for costs follows these rules:

  1. Colored mana requirements must be paid with the exact color
  2. Generic mana can be paid with any type of mana
  3. Colorless-specific requirements must be paid with colorless mana
  4. Special mana symbols follow their own rules

For more information on how mana and costs are implemented in Rummage, see:

Triggered Abilities

This document provides an overview of triggered abilities in Magic: The Gathering.

What Are Triggered Abilities?

Triggered abilities are abilities that automatically trigger when certain game events occur. They are written as "When/Whenever/At [event], [effect]."

Examples:

  • "When this creature enters the battlefield, draw a card."
  • "Whenever an opponent gains life, you may draw a card."
  • "At the beginning of your upkeep, you gain 1 life."

Triggers

Triggered abilities can trigger based on:

  1. State Changes: "When [state change] occurs..."

    • A creature entering the battlefield
    • A creature dying
    • A creature attacking or blocking
  2. Phase/Step Changes: "At the beginning of [phase]..."

    • At the beginning of upkeep
    • At the beginning of your draw step
    • At the beginning of combat
  3. Player Actions: "Whenever a player [action]..."

    • Whenever a player casts a spell
    • Whenever a player plays a land
    • Whenever a player activates an ability

Resolution Process

When a triggered ability triggers:

  1. The ability is put on the stack the next time a player would receive priority
  2. If multiple abilities trigger at the same time, they go on the stack in APNAP order (Active Player, Non-Active Player)
  3. If multiple abilities trigger for a single player, that player chooses the order
  4. Triggered abilities resolve like any other ability on the stack

Special Types of Triggered Abilities

Delayed Triggered Abilities

These set up an effect that will happen later:

  • "At the beginning of the next end step, return the exiled card to its owner's hand."
  • "At the beginning of your next upkeep, sacrifice this permanent."

State Triggers

These continually check if a condition is true:

  • "When you have no cards in hand, sacrifice this permanent."
  • "When you control no creatures, sacrifice this enchantment."

Implementation Note

For the detailed implementation of triggered abilities in Rummage, including code examples and integration with other systems, see:

These documents provide technical details on how triggered abilities are handled in the game engine.

Replacement Effects

This document provides an overview of replacement effects in Magic: The Gathering.

What Are Replacement Effects?

Replacement effects are continuous effects that watch for a particular event to occur and completely or partially replace that event with a different event. They use the words "instead of," "rather than," or "skip."

Examples:

  • "If you would draw a card, instead draw two cards."
  • "If a creature would die, exile it instead."
  • "If damage would be dealt to you, prevent that damage. You gain that much life instead."

How Replacement Effects Work

Replacement effects modify events before they occur:

  1. They don't use the stack and can't be responded to
  2. They apply before the event happens (not after)
  3. Only one replacement effect can apply to a particular event
  4. If multiple replacement effects could apply, the affected player or controller of the affected object chooses which to apply first
  5. After applying one replacement effect, the resulting event is checked again for other applicable replacement effects

Types of Replacement Effects

Self-Replacement Effects

These modify how a spell or ability resolves:

  • "Draw three cards. You lose 3 life unless you discard a card for each card drawn this way."

Prevention Effects

A special type of replacement effect that prevents damage:

  • "Prevent all damage that would be dealt to you and creatures you control this turn."
  • "If a source would deal damage to target creature, prevent 3 of that damage."

Redirection Effects

These change where an effect applies:

  • "If a source would deal damage to you, it deals that damage to target creature instead."

Multiple Replacement Effects

When multiple replacement effects could apply to the same event:

  1. The affected player (or controller of the affected object) chooses which to apply first
  2. After applying one effect, check if any remaining replacement effects still apply
  3. If so, the player chooses among those, and so on
  4. This continues until no more applicable replacement effects remain

Implementation Note

For information on the implementation of replacement effects in Rummage, see:

  • Effects: General effects documentation, if available
  • Static Abilities: Replacement effects are often implemented as static abilities

These documents provide technical details on how replacement effects are handled in the game engine.

Subgames and Game Restarting

Subgames

In Magic: The Gathering, a "subgame" is a complete game of Magic played within another game, most notably created by the card Shahrazad. Subgames have their own distinct game state, separate from the main game.

Shahrazad Implementation

Shahrazad is a sorcery that instructs players to leave the current game temporarily and play a subgame with their libraries as their decks. When the subgame concludes, the main game resumes, and the loser of the subgame loses half their life, rounded up.

Key implementation details:

  • The subgame creates a completely new game state with its own zones, life totals, etc.
  • Cards used in the subgame come from the players' libraries in the main game
  • Cards that leave the subgame (exile, graveyard, etc.) remain outside the subgame
  • When the subgame ends, all cards still in the subgame return to their owners' libraries in the main game
  • The main game's state is suspended but preserved entirely during the subgame

Technical Implementation

Our implementation of subgames utilizes a stack-based approach:

  1. When a subgame is created, we push the current game state onto a stack
  2. A new game state is initialized for the subgame with appropriate starting conditions
  3. The subgame runs as a complete game with its own turn structure and rules
  4. When the subgame concludes, we pop the previous game state from the stack and resume
  5. We apply any results from the subgame to the main game (like life loss)

Subgames can be nested, meaning a Shahrazad could be cast within a subgame created by another Shahrazad, creating sub-subgames.

Game Restarting

Some cards, like Karn Liberated, have the ability to restart the game. This differs from subgames in that the current game ends completely and a new game begins with modified starting conditions.

Karn Liberated Implementation

Karn Liberated's ultimate ability (-14 loyalty) restarts the game, with all cards in exile that were exiled with Karn put into their owners' hands in the new game.

Key implementation details:

  • The current game ends immediately
  • A new game begins with players at their starting life totals
  • Players draw new starting hands
  • Cards exiled with Karn's ability are returned to their owners' hands
  • All other cards return to their starting zones (library, command zone, etc.)

Technical Implementation

Our restart implementation:

  1. Tracks cards exiled with specific abilities like Karn's
  2. When a restart ability is triggered, saves references to the tracked exile cards
  3. Cleans up all game resources from the current game
  4. Initializes a new game with standard starting conditions
  5. Modifies the initial state to include returned exiled cards in their owners' hands

Differences Between Subgames and Restarting

FeatureSubgamesGame Restarting
Original game statePreservedEnded completely
Players' lifeUnchanged in main gameReset to starting amount
Cards from old gameOnly library cardsAll cards return to starting zones
ContinuityReturns to main game when doneOld game ended, only some cards carried over
ImplementationStack-based state managementComplete reinitialization

Integration with Game Engine

Both features leverage our snapshot system for state management:

  • Subgames use the snapshot system to save and restore the main game state
  • Game restarting uses snapshots to track exiled cards before reinitializing

See the Snapshot System documentation for more details on how state is managed for these complex mechanics.

Priority System

Overview

The priority system is a fundamental mechanism in Magic: The Gathering that determines when players can cast spells and activate abilities. This document explains how priority is implemented in Rummage and how it controls the flow of gameplay.

Priority Basics

In Magic: The Gathering, priority is the right to take an action such as casting a spell, activating an ability, or making a special game action. The priority system follows these key principles:

  1. The active player receives priority at the beginning of each step and phase, except for the untap step and most cleanup steps
  2. A player with priority may take an action or pass priority
  3. When all players pass priority in succession without taking an action, the top object on the stack resolves or, if the stack is empty, the current step or phase ends
  4. Whenever an object is put on the stack, all players pass priority, and the stack resolves, priority is given to the active player

Implementation in Rummage

In Rummage, the priority system is implemented using a combination of resources and systems:

#![allow(unused)]
fn main() {
/// Resource tracking priority state
#[derive(Resource)]
pub struct PrioritySystem {
    /// Current player with priority
    pub current_priority: Option<Entity>,
    /// Players who have passed priority without taking an action
    pub passed_players: HashSet<Entity>,
    /// Whether the game is currently waiting for priority actions
    pub waiting_for_priority: bool,
    /// The last player to take an action
    pub last_action_player: Option<Entity>,
}

/// Component marking the active player
#[derive(Component)]
pub struct ActivePlayer;
}

Priority Assignment

Priority is assigned according to turn order, starting with the active player:

#![allow(unused)]
fn main() {
pub fn assign_priority_system(
    mut priority_system: ResMut<PrioritySystem>,
    turn_system: Res<TurnSystem>,
    game_state: Res<GameState>,
    active_player_query: Query<Entity, With<ActivePlayer>>,
) {
    // Only assign priority if not already waiting for someone
    if priority_system.waiting_for_priority {
        return;
    }

    // When a phase or step begins, active player gets priority
    if game_state.pending_phase_change {
        let active_player = active_player_query.get_single().unwrap();
        priority_system.current_priority = Some(active_player);
        priority_system.passed_players.clear();
        priority_system.waiting_for_priority = true;
    }

    // After stack resolution, active player gets priority
    if game_state.stack_just_resolved {
        let active_player = active_player_query.get_single().unwrap();
        priority_system.current_priority = Some(active_player);
        priority_system.passed_players.clear();
        priority_system.waiting_for_priority = true;
    }
}
}

Passing Priority

When a player passes priority, it's passed to the next player in turn order:

#![allow(unused)]
fn main() {
pub fn handle_pass_priority(
    mut priority_system: ResMut<PrioritySystem>,
    mut pass_priority_events: EventReader<PassPriorityEvent>,
    players: Query<(Entity, &Player)>,
    turn_system: Res<TurnSystem>,
    mut game_events: EventWriter<GameEvent>,
) {
    for event in pass_priority_events.iter() {
        // Only process if this player currently has priority
        if let Some(current_priority) = priority_system.current_priority {
            if current_priority == event.player {
                // Add player to passed list
                priority_system.passed_players.insert(current_priority);
                
                // Find next player in turn order
                let next_player = get_next_player_in_turn_order(
                    current_priority, 
                    &players, 
                    &turn_system
                );
                
                // If next player has already passed, check for all players passing
                if priority_system.passed_players.contains(&next_player) || 
                   priority_system.passed_players.len() == players.iter().count() {
                    // All players have passed priority
                    game_events.send(GameEvent::AllPlayersPassed);
                    priority_system.waiting_for_priority = false;
                    priority_system.current_priority = None;
                } else {
                    // Pass to next player
                    priority_system.current_priority = Some(next_player);
                    game_events.send(GameEvent::PriorityPassed {
                        from: current_priority,
                        to: next_player,
                    });
                }
            }
        }
    }
}
}

Priority After Actions

After a player takes an action, they receive priority again:

#![allow(unused)]
fn main() {
pub fn handle_player_action(
    mut priority_system: ResMut<PrioritySystem>,
    mut player_action_events: EventReader<PlayerActionEvent>,
    mut game_events: EventWriter<GameEvent>,
) {
    for event in player_action_events.iter() {
        // Record player taking action
        priority_system.last_action_player = Some(event.player);
        
        // Clear passed players list since an action was taken
        priority_system.passed_players.clear();
        
        // Return priority to the player who took action
        priority_system.current_priority = Some(event.player);
        priority_system.waiting_for_priority = true;
        
        game_events.send(GameEvent::PriorityAssigned {
            player: event.player,
            reason: PriorityReason::AfterAction,
        });
    }
}
}

Stack Resolution

When all players pass priority, either the top of the stack resolves or the current phase/step ends:

#![allow(unused)]
fn main() {
pub fn handle_all_players_passed(
    mut commands: Commands,
    mut stack: ResMut<Stack>,
    mut game_state: ResMut<GameState>,
    mut priority_system: ResMut<PrioritySystem>,
    active_player_query: Query<Entity, With<ActivePlayer>>,
    mut game_events: EventWriter<GameEvent>,
) {
    // Process what happens when all players pass priority
    if !stack.items.is_empty() {
        // Resolve top item of stack
        let top_item = stack.items.pop().unwrap();
        
        game_events.send(GameEvent::StackItemResolved {
            item_id: top_item.id,
        });
        
        // Mark for priority reassignment after resolution
        game_state.stack_just_resolved = true;
    } else {
        // Empty stack, end current phase/step
        game_state.proceed_to_next_phase_or_step();
        
        game_events.send(GameEvent::PhaseStepEnded {
            phase: game_state.current_phase.clone(),
            step: game_state.current_step.clone(),
        });
    }
}
}

Special Priority Rules

Special Timing Rules

Some game actions use special timing rules that modify how priority works:

#![allow(unused)]
fn main() {
pub fn handle_special_timing(
    mut commands: Commands,
    mut priority_system: ResMut<PrioritySystem>,
    mut special_action_events: EventReader<SpecialActionEvent>,
    mut game_events: EventWriter<GameEvent>,
) {
    for event in special_action_events.iter() {
        match event.action_type {
            SpecialActionType::PlayLand => {
                // Playing a land doesn't use the stack and doesn't pass priority
                // but does count as taking an action for priority purposes
                priority_system.last_action_player = Some(event.player);
                priority_system.passed_players.clear();
                priority_system.current_priority = Some(event.player);
                
                game_events.send(GameEvent::PriorityRetained {
                    player: event.player,
                    reason: "Played a land",
                });
            },
            SpecialActionType::ManaAbility => {
                // Mana abilities don't use the stack and don't change priority
                game_events.send(GameEvent::ManaAbilityResolved {
                    player: event.player,
                    source: event.source,
                });
            },
            // Other special timing rules...
        }
    }
}
}

State-Based Actions

State-based actions are checked before a player would receive priority:

#![allow(unused)]
fn main() {
pub fn check_state_based_actions(
    mut commands: Commands,
    mut priority_system: ResMut<PrioritySystem>,
    mut game_state: ResMut<GameState>,
    // Other query parameters...
) {
    // Only check when a player would receive priority
    if !priority_system.waiting_for_priority && 
       game_state.stack_just_resolved {
        // Process all state-based actions
        let sba_performed = perform_state_based_actions(
            &mut commands,
            // Other parameters...
        );
        
        // If any state-based actions were performed, check again
        // before assigning priority
        if sba_performed {
            game_state.sba_check_needed = true;
        } else {
            game_state.sba_check_needed = false;
            game_state.stack_just_resolved = false;
        }
    }
}
}

Turn-Based Actions

Turn-based actions happen automatically at specific points regardless of priority:

#![allow(unused)]
fn main() {
pub fn handle_turn_based_actions(
    mut commands: Commands,
    game_state: Res<GameState>,
    mut turn_action_events: EventWriter<TurnBasedActionEvent>,
) {
    match game_state.current_phase {
        Phase::Beginning if game_state.current_step == Step::Draw => {
            // Draw step: Active player draws a card
            turn_action_events.send(TurnBasedActionEvent::ActivePlayerDraws);
        },
        Phase::Combat if game_state.current_step == Step::EndOfCombat => {
            // End of combat: Remove all creatures from combat
            turn_action_events.send(TurnBasedActionEvent::RemoveFromCombat);
        },
        // Other turn-based actions...
        _ => {}
    }
}
}

UI Integration

The priority system is visually represented to players:

#![allow(unused)]
fn main() {
pub fn update_priority_ui(
    priority_system: Res<PrioritySystem>,
    stack: Res<Stack>,
    players: Query<(Entity, &Player, &PlayerName)>,
    mut ui_state: ResMut<UiState>,
) {
    if let Some(current_priority) = priority_system.current_priority {
        // Highlight player with priority
        if let Ok((_, _, name)) = players.get(current_priority) {
            ui_state.priority_indicator = Some(PriorityIndicator {
                player: current_priority,
                name: name.0.clone(),
                has_passed: false,
            });
        }
    } else {
        ui_state.priority_indicator = None;
    }
    
    // Update UI for players who have passed
    for entity in &priority_system.passed_players {
        if let Ok((_, _, name)) = players.get(*entity) {
            ui_state.passed_priority_indicators.push(PriorityIndicator {
                player: *entity,
                name: name.0.clone(),
                has_passed: true,
            });
        }
    }
    
    // Update stack indicator
    ui_state.stack_size = stack.items.len();
}
}

Multiplayer Priority

In multiplayer games, priority follows turn order (APNAP - Active Player, Non-Active Player):

#![allow(unused)]
fn main() {
pub fn get_next_player_in_turn_order(
    current_player: Entity,
    players: &Query<(Entity, &Player)>,
    turn_system: &Res<TurnSystem>,
) -> Entity {
    let turn_order = &turn_system.player_order;
    let current_index = turn_order.iter()
        .position(|&p| p == current_player)
        .unwrap_or(0);
    
    // Get next player, wrapping around to the beginning
    let next_index = (current_index + 1) % turn_order.len();
    turn_order[next_index]
}
}

Testing Priority Flow

The priority system is tested through a variety of scenarios:

#![allow(unused)]
fn main() {
#[test]
fn test_priority_basic_flow() {
    // Setup test environment
    let mut app = App::new();
    app.add_plugins(MinimalPlugins)
       .add_system(assign_priority_system)
       .add_system(handle_pass_priority)
       .add_system(handle_player_action)
       .add_system(handle_all_players_passed)
       // Other test setup...
       
    // Create test players
    let player1 = spawn_test_player(&mut app.world, "Player 1");
    let player2 = spawn_test_player(&mut app.world, "Player 2");
    
    // Set active player
    app.world.entity_mut(player1).insert(ActivePlayer);
    
    // Begin test phase
    let mut game_state = app.world.resource_mut::<GameState>();
    game_state.pending_phase_change = true;
    
    // Run systems
    app.update();
    
    // Verify active player got priority
    let priority_system = app.world.resource::<PrioritySystem>();
    assert_eq!(priority_system.current_priority, Some(player1));
    
    // Simulate player1 passing priority
    app.world.resource_mut::<Events<PassPriorityEvent>>()
        .send(PassPriorityEvent { player: player1 });
    
    // Run systems
    app.update();
    
    // Verify priority passed to player2
    let priority_system = app.world.resource::<PrioritySystem>();
    assert_eq!(priority_system.current_priority, Some(player2));
    
    // More test assertions...
}
}

Edge Cases

The priority system handles several edge cases:

Split Second

The "split second" keyword prevents players from casting spells or activating non-mana abilities while a spell with split second is on the stack:

#![allow(unused)]
fn main() {
pub fn handle_split_second(
    stack: Res<Stack>,
    mut priority_system: ResMut<PrioritySystem>,
    mut player_action_events: EventReader<PlayerActionEvent>,
    mut game_events: EventWriter<GameEvent>,
) {
    // Check if a split second spell is on the stack
    let split_second_active = stack.items.iter().any(|item| {
        if let StackItemType::Spell { abilities, .. } = &item.item_type {
            abilities.contains(&Ability::SplitSecond)
        } else {
            false
        }
    });
    
    if split_second_active {
        // Only allow certain actions while split second is active
        for event in player_action_events.iter() {
            match event.action_type {
                ActionType::ActivateManaAbility(_) => {
                    // Mana abilities are allowed
                    // Process normally
                },
                ActionType::TriggerSpecialAction(SpecialActionType::MorphFaceDown) => {
                    // Special actions like turning a face-down creature face up are allowed
                    // Process normally
                },
                _ => {
                    // All other actions are denied
                    game_events.send(GameEvent::ActionDenied {
                        player: event.player,
                        action_type: event.action_type.clone(),
                        reason: "Split second prevents this action",
                    });
                    continue;
                }
            }
        }
    }
}
}

No Action Possible

If a player cannot take any action, they must pass priority:

#![allow(unused)]
fn main() {
pub fn handle_no_action_possible(
    mut priority_system: ResMut<PrioritySystem>,
    players: Query<(Entity, &Player, &Hand)>,
    mut pass_priority_events: EventWriter<PassPriorityEvent>,
    mut game_events: EventWriter<GameEvent>,
) {
    if let Some(current_priority) = priority_system.current_priority {
        if let Ok((entity, player, hand)) = players.get(current_priority) {
            // Check if player can take any action
            if hand.cards.is_empty() && !player_has_playable_permanents(entity) {
                // Player has no possible actions, auto-pass priority
                pass_priority_events.send(PassPriorityEvent { player: entity });
                
                game_events.send(GameEvent::AutoPassPriority {
                    player: entity,
                    reason: "No possible actions",
                });
            }
        }
    }
}
}

Conclusion

The priority system is a fundamental aspect of Magic: The Gathering that controls the flow of game actions. Rummage's implementation carefully follows the official rules, ensuring that players can take actions in the correct order and that game state advances properly.


Next: Stack

Layer System

Overview

The Layer System is one of Magic: The Gathering's most complex rule mechanisms, designed to handle the application of continuous effects in a consistent, predictable order. This document explains how the Layer System is implemented in Rummage and how it resolves potentially conflicting continuous effects.

The Seven Layers

According to the MTG Comprehensive Rules (section 613), continuous effects are applied in a specific order across seven distinct layers:

  1. Copy Effects - Effects that modify how an object is copied
  2. Control-Changing Effects - Effects that change control of an object
  3. Text-Changing Effects - Effects that change an object's text
  4. Type-Changing Effects - Effects that change an object's types, subtypes, or supertypes
  5. Color-Changing Effects - Effects that change an object's colors
  6. Ability-Adding/Removing Effects - Effects that add or remove abilities
  7. Power/Toughness-Changing Effects - Effects that modify a creature's power and/or toughness

Within the Power/Toughness layer (layer 7), effects are applied in this sub-order:

  • 7a. Effects from characteristic-defining abilities
  • 7b. Effects that set power and/or toughness to a specific value
  • 7c. Effects that modify power and/or toughness (but don't set them)
  • 7d. Effects from counters on the creature
  • 7e. Effects that switch power and toughness

Implementation in Rummage

In Rummage, the Layer System is implemented using a combination of components, resources, and systems:

#![allow(unused)]
fn main() {
/// Resource that manages continuous effects
#[derive(Resource)]
pub struct ContinuousEffectsSystem {
    pub effects: Vec<ContinuousEffect>,
    pub timestamp_counter: u64,
}

/// Representation of a continuous effect
#[derive(Clone, Debug)]
pub struct ContinuousEffect {
    pub id: Uuid,
    pub source: Entity,
    pub layer: Layer,
    pub effect_type: ContinuousEffectType,
    pub targets: EffectTargets,
    pub duration: EffectDuration,
    pub timestamp: u64,
    pub dependency_info: Option<DependencyInfo>,
}

/// Enumeration of the different layers
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub enum Layer {
    CopyEffects = 1,
    ControlChangingEffects = 2,
    TextChangingEffects = 3,
    TypeChangingEffects = 4,
    ColorChangingEffects = 5,
    AbilityAddingEffects = 6,
    PowerToughnessCharDefining = 7,
    PowerToughnessSetValue = 8,
    PowerToughnessModify = 9,
    PowerToughnessCounters = 10,
    PowerToughnessSwitch = 11,
}
}

Application Process

Continuous effects are applied through a multi-stage process:

#![allow(unused)]
fn main() {
pub fn apply_continuous_effects_system(
    mut commands: Commands,
    effects_system: Res<ContinuousEffectsSystem>,
    mut card_query: Query<(Entity, &mut Card, &mut ContinuousEffectsApplied)>,
    // Other query parameters...
) {
    // Group effects by layer
    let mut effects_by_layer: HashMap<Layer, Vec<&ContinuousEffect>> = HashMap::new();
    
    for effect in &effects_system.effects {
        if effect.is_active() {
            effects_by_layer.entry(effect.layer)
                .or_insert_with(Vec::new)
                .push(effect);
        }
    }
    
    // Sort each layer's effects by timestamp
    for effects in effects_by_layer.values_mut() {
        effects.sort_by_key(|effect| effect.timestamp);
    }
    
    // Apply effects layer by layer
    for layer in Layer::iter() {
        if let Some(effects) = effects_by_layer.get(&layer) {
            apply_layer_effects(layer, effects, &mut commands, &mut card_query);
        }
    }
}
}

Dependency Handling

One of the most intricate parts of the Layer System is handling dependencies between effects. According to the rules, if applying one effect would change what another effect does or what it applies to, there's a dependency:

#![allow(unused)]
fn main() {
pub fn detect_dependencies(effects: &mut [ContinuousEffect]) {
    for i in 0..effects.len() {
        for j in 0..effects.len() {
            if i != j {
                if does_effect_depend_on(
                    &effects[i], 
                    &effects[j], 
                    // Other parameters to check dependency
                ) {
                    effects[i].dependency_info = Some(DependencyInfo {
                        depends_on: effects[j].id,
                    });
                }
            }
        }
    }
    
    // Reorder effects based on dependencies
    reorder_effects_by_dependencies(effects);
}
}

Layer System Examples

Example 1: Layers 7b and 7c Interaction

Consider these two effects:

  1. Humility (Layer 7b): "All creatures lose all abilities and have base power and toughness 1/1."
  2. Glorious Anthem (Layer 7c): "Creatures you control get +1/+1."
#![allow(unused)]
fn main() {
// First, in layer 7b, set all creatures to 1/1
for (entity, mut card, _) in creature_query.iter_mut() {
    if let CardDetails::Creature(ref mut creature) = card.details.details {
        // Apply Humility effect (Layer 7b - sets P/T)
        creature.power = 1;
        creature.toughness = 1;
    }
}

// Then, in layer 7c, apply the +1/+1 bonus to controlled creatures
for (entity, mut card, controller) in creature_query.iter_mut() {
    if controller.controller == anthem_controller {
        if let CardDetails::Creature(ref mut creature) = card.details.details {
            // Apply Glorious Anthem effect (Layer 7c - modifies P/T)
            creature.power += 1;
            creature.toughness += 1;
        }
    }
}
}

Example 2: Dependency Between Layers

Consider these effects:

  1. Conspiracy (Layer 4): "All creatures in your hand, library, graveyard, and battlefield are Elves."
  2. Elvish Champion (Layer 6): "All Elves get +1/+1 and have forestwalk."

Here, the effect of Elvish Champion depends on what creatures are Elves, which is affected by Conspiracy:

#![allow(unused)]
fn main() {
// Detect the dependency
pub fn detect_type_dependent_abilities(effects: &mut [ContinuousEffect]) {
    for i in 0..effects.len() {
        for j in 0..effects.len() {
            if i != j {
                let effect_i = &effects[i];
                let effect_j = &effects[j];
                
                // If effect_i adds abilities to a specific type
                if let ContinuousEffectType::AddAbilities { type_requirement: Some(type_req), .. } = &effect_i.effect_type {
                    // And effect_j changes types
                    if let ContinuousEffectType::ChangeTypes { .. } = &effect_j.effect_type {
                        // Then effect_i depends on effect_j
                        effects[i].dependency_info = Some(DependencyInfo {
                            depends_on: effect_j.id,
                        });
                    }
                }
            }
        }
    }
}
}

Timestamp Ordering

When multiple effects apply in the same layer and don't have dependencies, they're applied in timestamp order:

#![allow(unused)]
fn main() {
pub fn add_continuous_effect(
    mut effects_system: ResMut<ContinuousEffectsSystem>,
    effect_data: ContinuousEffectData,
) {
    let timestamp = effects_system.timestamp_counter;
    effects_system.timestamp_counter += 1;
    
    let effect = ContinuousEffect {
        id: Uuid::new_v4(),
        layer: effect_data.layer,
        effect_type: effect_data.effect_type,
        // Other fields...
        timestamp,
        dependency_info: None,
    };
    
    effects_system.effects.push(effect);
}
}

Characteristic-Defining Abilities (CDAs)

Layer 7a handles characteristic-defining abilities, which are abilities that define a creature's power/toughness, like Tarmogoyf's "/+1 equal to the number of card types in all graveyards":

#![allow(unused)]
fn main() {
pub fn apply_characteristic_defining_abilities(
    mut card_query: Query<(Entity, &mut Card, &CardAbilities)>,
) {
    for (entity, mut card, abilities) in card_query.iter_mut() {
        for ability in &abilities.characteristic_defining {
            match ability {
                CharacteristicDefiningAbility::PowerToughness(calculator) => {
                    if let CardDetails::Creature(ref mut creature) = card.details.details {
                        let (power, toughness) = calculator(entity);
                        creature.power = power;
                        creature.toughness = toughness;
                    }
                },
                // Other CDA types...
            }
        }
    }
}
}

Testing the Layer System

Testing the layer system is complex due to the numerous potential interactions. Rummage uses a combination of unit tests for each layer and integration tests for complex scenarios:

#![allow(unused)]
fn main() {
#[test]
fn test_layers_example_scenario() {
    // Setup test environment
    let mut app = App::new();
    app.add_plugins(MinimalPlugins)
       .add_systems(Update, apply_continuous_effects_system)
       .init_resource::<ContinuousEffectsSystem>();
    
    // Create test cards and effects
    let humility = spawn_humility(&mut app.world);
    let anthem = spawn_anthem(&mut app.world);
    let test_creature = spawn_test_creature(&mut app.world, 2, 2);
    
    // Run systems to apply effects
    app.update();
    
    // Verify the combined effect: creature should be 2/2
    let (_, card, _) = app.world
        .query::<(Entity, &Card, &ContinuousEffectsApplied)>()
        .get(&app.world, test_creature)
        .unwrap();
    
    if let CardDetails::Creature(creature) = &card.details.details {
        assert_eq!(creature.power, 2);
        assert_eq!(creature.toughness, 2);
    } else {
        panic!("Expected a creature!");
    }
}
}

Edge Cases and Advanced Interactions

The layer system must handle many edge cases:

Self-Replacement Effects

When an effect modifies how itself works:

#![allow(unused)]
fn main() {
pub fn handle_self_replacement_effects(effects: &mut [ContinuousEffect]) {
    // Identify and mark self-replacement effects
    let mut self_replacing = Vec::new();
    
    for (idx, effect) in effects.iter().enumerate() {
        if is_self_replacing(effect) {
            self_replacing.push(idx);
        }
    }
    
    // Apply them in a special order
    // ...
}
}

Copiable Values

Layer 1 deals with what values are copied when an effect copies an object:

#![allow(unused)]
fn main() {
pub fn determine_copiable_values(
    source: Entity,
    target: Entity,
    card_query: &Query<(Entity, &Card)>,
) -> Card {
    // Get the base card to copy
    let (_, source_card) = card_query.get(source).unwrap();
    
    // Create a copy with only the copiable values
    Card {
        name: source_card.name.clone(),
        cost: source_card.cost.clone(),
        type_info: source_card.type_info.clone(),
        details: source_card.details.clone(),
        rules_text: source_card.rules_text.clone(),
        keywords: KeywordAbilities::default(), // Keywords aren't copied in layer 1
    }
}
}

Conclusion

The Layer System is one of the most complex parts of the Magic: The Gathering rules, but its step-by-step approach ensures that continuous effects are applied consistently. Rummage's implementation carefully follows these rules to ensure games play out correctly, even in the most complex scenarios.


Next: Triggered Abilities

Contribution Guidelines

This document provides an overview of how to contribute to the Rummage project. For detailed guidelines on specific aspects of contribution, please refer to the following resources:

We welcome contributions from everyone. By participating in this project, you agree to abide by our code of conduct.

Getting Started

  1. Fork the repository
  2. Clone your fork locally
  3. Set up the development environment
  4. Create a new branch for your feature or bug fix
  5. Make your changes following our coding standards
  6. Write or update tests as needed
  7. Commit your changes with clear, descriptive commit messages (see Git Workflow Guidelines)
  8. Push your branch to your fork
  9. Submit a pull request to the main repository

Pull Request Process

  1. Ensure your code passes all tests and linting checks
  2. Update documentation as necessary
  3. Include a clear description of the changes in your pull request
  4. Link any related issues using the appropriate GitHub syntax
  5. Wait for a maintainer to review your pull request
  6. Address any feedback provided by reviewers

Thank you for contributing to Rummage!

Documentation Guidelines

This page contains the comprehensive documentation guidelines for the Rummage MTG Commander game engine built with Bevy 0.15.x.

Documentation Structure

The documentation is organized into the following major sections:

  1. Commander Rules - Implementation of MTG Commander format rules and mechanics
  2. Game UI - User interface systems and components
  3. Networking - Multiplayer functionality using bevy_replicon

Contributing to Documentation

When contributing to the documentation, please follow these guidelines:

Document Structure

Each document should have:

  1. A clear, descriptive title
  2. A brief introduction explaining the document's purpose
  3. A table of contents for documents longer than a few paragraphs
  4. Properly nested headings (H1 -> H2 -> H3)
  5. Code examples where appropriate
  6. Cross-references to related documents
  7. Implementation status indicators where appropriate

Style Guide

  1. Use American English spelling and grammar
  2. Write in a clear, concise style
  3. Use active voice where possible
  4. Keep paragraphs focused on a single topic
  5. Use proper Markdown formatting:
    • # for headings (not underlines)
    • Backticks for code snippets
    • Triple backticks for code blocks with language identifier
    • Dashes for unordered lists
    • Numbers for ordered lists

Code Examples

Include meaningful code examples that:

  1. Use non-deprecated Bevy 0.15.x APIs
  2. Demonstrate the concept being explained
  3. Are syntactically correct
  4. Include comments for complex code
  5. Use Rust syntax highlighting with ````rust`

Implementation Status

Use the following indicators to mark implementation status:

  • ✅ Implemented and tested
  • 🔄 In progress
  • ⚠️ Planned but not yet implemented

Building the Documentation

We use mdbook to build the documentation. To get started:

  1. Install required tools:

    make install-tools
    
  2. Build the documentation:

    make build
    
  3. View the documentation locally:

    make serve
    

Then open your browser to http://localhost:3000

Documentation Maintenance

Use the following make commands for documentation maintenance:

  • make lint - Check for style issues
  • make toc - Generate table of contents
  • make check - Check for broken links
  • make validate - Validate documentation structure
  • make update-dates - Update last modified dates

Questions?

If you have questions about the documentation, please contact the documentation team or open an issue in the project repository.

Git Workflow Guidelines

This document outlines the recommended git workflow for contributing to the Rummage project, with a special focus on writing clear, informative commit messages.

Commit Message Guidelines

Good commit messages are essential for maintaining a clean, understandable project history. They help with:

  • Understanding changes at a glance
  • Generating accurate changelogs
  • Tracking the evolution of features and bug fixes
  • Making code reviews more efficient
  • Facilitating future maintenance and debugging

Commit Message Structure

Each commit message should follow this structure:

<type>: <subject>

<body>

<footer>

Type

Begin your commit message with one of these types to categorize the change:

TypeDescription
featA new feature
fixA bug fix
docsDocumentation changes
styleCode style changes (formatting, indentation)
refactorCode refactoring (no functional changes)
perfPerformance improvements
testAdding or updating tests
choreMaintenance tasks, build changes, etc.
ciCI/CD related changes

Subject

The subject line is a brief summary of the change:

  • Write in imperative mood (e.g., "Add" not "Added" or "Adds")
  • Keep it under 50 characters
  • Don't end with a period
  • Capitalize the first letter

Body

The commit body provides more detailed information:

  • Separate from subject with a blank line
  • Explain the "what" and "why" (not "how")
  • Use bullet points when appropriate (- or *)
  • Wrap text at ~72 characters
  • Reference issues and user stories when applicable

The footer contains metadata about the commit:

  • Reference issues with "Fixes #123" or "Closes #123"
  • Breaking changes should be noted with "BREAKING CHANGE:"

Examples

Here are examples of well-formatted commit messages:

feat: Add user authentication system

Implement JWT-based authentication flow with refresh tokens.
- Add login/logout endpoints
- Create token generation service
- Add middleware for protected routes

Closes #45
fix: Prevent race condition in database connection pool

When multiple requests arrived simultaneously during
initialization, connections were being created beyond
the configured maximum. Added mutex lock to ensure
proper counting.

Fixes #123
docs: Update API documentation with authentication examples

Add code samples for all authentication flows and
improve explanations of token usage.

Best Practices

Commit Organization

  • When possible, limit commits to a single logical change
  • Small, focused commits are easier to understand and review
  • Avoid mixing multiple unrelated changes in a single commit
  • When incorporating feedback from PR reviews, reference the PR number

Working with Branches

  1. Create feature branches from the main branch

    git checkout -b feature/my-new-feature main
    
  2. Make regular, focused commits

    git commit -m "feat: Add validation for user input fields"
    
  3. Rebase your branch on main before submitting a PR

    git checkout main
    git pull
    git checkout feature/my-new-feature
    git rebase main
    
  4. Squash commits if needed to maintain a clean history

    git rebase -i HEAD~3  # Squash last 3 commits
    

Pull Request Guidelines

  • Ensure PR title follows the same commit message format
  • Include a detailed description of changes
  • Reference related issues
  • Ensure all tests pass
  • Request reviews from appropriate team members

Git Configuration Tips

You can configure git to use your preferred editor for commit messages:

git config --global core.editor "code --wait"  # For VS Code

For multi-line commit messages, use:

git commit  # Opens editor for detailed message

Conclusion

Following these commit message guidelines helps maintain a high-quality codebase with a clear, navigable history. Good commit messages are a form of documentation that helps future contributors (including your future self) understand the evolution of the project.

Glossary

This glossary provides definitions for both Magic: The Gathering game terms and Rummage-specific technical terms.

A

Ability

A feature of a card that gives it certain characteristics or allows it to perform certain actions. There are three main types: activated, triggered, and static abilities.

Activated Ability

An ability that a player can activate by paying its cost (e.g., tapping the card, paying mana).

Active Player

The player whose turn it is currently.

APNAP Order

The order in which players make decisions when multiple players need to take actions: Active Player, Non-Active Player.

Attachment

A card or effect that modifies another card (e.g., Auras, Equipment).

B

Battlefield

The zone where permanents (lands, creatures, artifacts, enchantments, and planeswalkers) exist while in play.

Bevy

The game engine used to build Rummage, featuring an entity-component-system (ECS) architecture.

Bevy ECS

The entity-component-system architecture used in Bevy, which separates entities (objects), components (data), and systems (logic).

Bundle

(Bevy) A collection of components that are commonly added to an entity together. In Bevy 0.15.x, many bundles are deprecated in favor of individual components.

C

Card Type

The classification of a Magic card (e.g., creature, instant, sorcery, land).

Cast

To play a spell by putting it on the stack and paying its costs.

Color Identity

The colors in a card's mana cost, color indicator, and rules text. In Commander, a card can only be included in a deck if its color identity is within the commander's color identity.

Combat Phase

The phase of a turn where creatures can attack and block, consisting of Beginning of Combat, Declare Attackers, Declare Blockers, Combat Damage, and End of Combat steps.

Commander

  1. The legendary creature or planeswalker that leads a Commander deck.
  2. A casual multiplayer format of Magic where each player builds a 100-card deck around a legendary creature or planeswalker.

Commander Tax

The additional cost of {2} that must be paid for each time a commander has been cast from the command zone.

Component

(Bevy) A piece of data that can be attached to an entity, representing a specific aspect of that entity.

Context

(Rummage) An object containing information about the current game state, used when resolving effects.

Counter (noun)

A marker placed on a card to track various characteristics (e.g., +1/+1 counters, loyalty counters).

Counter (verb)

To respond to a spell or ability by preventing it from resolving.

D

Damage

A reduction in a creature's toughness or a player's life total. Unlike loss of life, damage can be prevented.

Deck

A collection of cards a player brings to the game. In Commander, decks consist of 99 cards plus a commander.

Deserialization

(Rummage) The process of converting serialized data back into game objects, used for loading games or processing network data.

Deterministic RNG

(Rummage) A random number generator that produces the same sequence of values when initialized with the same seed, crucial for networked gameplay.

Detrimental

(Rummage) When using Bevy's component lifetimes system, a tag indicating that an entity can exist without the marked component.

E

Effect

The result of a spell or ability resolving, which may change the game state.

Entity

(Bevy) A unique identifier that can have components attached to it, representing objects in the game.

Entity Component System (ECS)

(Bevy) A software architectural pattern that separates identity (entities), data (components), and behavior (systems).

Exile

A zone where cards removed from the game are placed.

F

Flash

A keyword ability that allows a player to cast a spell any time they could cast an instant.

Floating Mana

Mana in a player's mana pool that hasn't been spent yet. It disappears at the end of steps and phases.

G

Game State

The complete status of the game at a given moment, including all zones, cards, and player information.

GlobalTransform

(Bevy) A component that stores the absolute position, rotation, and scale of an entity in world space.

Graveyard

The zone where cards go when they're destroyed, sacrificed, discarded, or countered.

H

Hand

The zone where players hold cards they've drawn but haven't yet played.

Haste

A keyword ability that allows a creature to attack and use tap abilities the turn it comes under a player's control.

I

Indestructible

A keyword ability that prevents a permanent from being destroyed by damage or effects that say "destroy."

Instant

A card type that can be cast at any time, even during an opponent's turn.

IsServer

(Rummage) A resource indicating whether the current instance is running as a server or client.

K

Keyword Ability

An ability that is represented by a single word or phrase (e.g., Flying, Trample, Deathtouch).

L

Library

The zone where a player's deck is kept during the game.

Life Total

The amount of life a player has. In Commander, players typically start with 40 life.

Legendary

A supertype that restricts a player to controlling only one copy of a specific legendary permanent at a time.

M

Mana

The resource used to cast spells and activate abilities, represented by the five colors (White, Blue, Black, Red, Green) and colorless.

Mana Cost

The amount and type of mana required to cast a spell, shown in the upper right corner of a card.

Mana Pool

A holding area where mana exists from the time it's generated until it's spent or lost.

Marker Component

(Bevy) A component with no data that is used to tag entities for queries or to indicate a state.

Mulligan

The act of drawing a new hand at the start of the game, with one fewer card each time.

N

NetworkConfig

(Rummage) A resource containing configuration for networking, such as ports and connection settings.

Non-Active Player

Any player whose turn it is not.

P

Permanent

A card or token on the battlefield: creatures, artifacts, enchantments, lands, and planeswalkers.

PendingSnapshots

(Rummage) A resource that tracks snapshots waiting to be processed.

Phase

A segment of a turn. Each turn consists of five phases: Beginning, Precombat Main, Combat, Postcombat Main, and Ending.

Plugin

(Bevy) A module that adds specific functionality to the game, typically containing resources, components, and systems.

Priority

The right to take game actions, which passes between players throughout the turn.

Q

Query

(Bevy) A way to access entities and their components in systems, optionally filtered by component types.

R

Replicate

(Rummage/Replicon) A component that marks an entity for network replication.

Replicon

(Rummage) The networking library used for multiplayer functionality.

Resource

(Bevy) Global data not tied to any specific entity, accessed by systems.

Response

Playing a spell or ability after another spell or ability has been put on the stack but before it resolves.

S

Serialization

(Rummage) The process of converting game objects into a format that can be stored or transmitted.

Snapshot

(Rummage) A serialized representation of the game state at a specific point in time.

SnapshotEvent

(Rummage) An event that triggers taking, applying, saving, or loading a game snapshot.

Snapshotable

(Rummage) A marker component indicating that an entity should be included in snapshots.

Sorcery

A card type that can only be cast during a player's main phase when the stack is empty.

Stack

The zone where spells and abilities go while waiting to resolve.

State-Based Action

Game rules that automatically check and update the game state, such as putting creatures with lethal damage into the graveyard.

Step

A subdivision of a phase in a turn.

System

(Bevy) A function that operates on components, implementing game logic.

SystemSet

(Bevy) A collection of systems that can be configured together for ordering and dependencies.

T

Tap

To turn a card sideways to indicate it has been used.

Target

An object or player chosen to be affected by a spell or ability.

Transform

(Bevy) A component that stores the relative position, rotation, and scale of an entity.

Triggered Ability

An ability that automatically triggers when a specific event occurs.

Turn

A full cycle through all phases for a single player.

U

UI

User interface elements that display game information and accept player input.

Untap

To return a tapped card to its upright position, typically done at the beginning of a player's turn.

Update

(Bevy) The main schedule where most game systems run each frame.

W

Winning the Game

In Commander, a player wins by reducing all opponents' life totals to 0, dealing 21 or more combat damage with a single commander to a player, or through alternative win conditions on cards.

World

(Bevy) The container for all entities, components, and resources in the ECS.

Z

Zone

A place where cards can exist during the game: library, hand, battlefield, graveyard, stack, exile, and command.


Note: This glossary is continuously updated as new terms are added to the codebase or as Magic: The Gathering terminology evolves.

Contains info on a Wayland permissions bug and missing symlinks in current wsl2 as of the time of writing. https://github.com/microsoft/WSL/issues/11542

https://github.com/microsoft/wslg/issues/1032#issuecomment-2345292609

WSL2 Audio Issues

To fix audio issues in WSL2:

  1. Follow the solution described in this GitHub comment: https://github.com/microsoft/WSL/issues/2187#issuecomment-2605861048

  2. Add the following line to your .bashrc file:

    export PULSE_SERVER=unix:/mnt/wslg/PulseServer
    
  3. Source your .bashrc file or restart your WSL2 terminal:

    source ~/.bashrc
    
  4. Install Alsa/Pulse deps

    sudo apt install libasound2t64 libasound2-plugins libpulse0
    

Save/Load System

The save/load system provides robust functionality to persist and restore game states in Rummage's MTG Commander format game engine. This allows games to be saved, loaded, and replayed at any point.

Key Features

  • Binary Serialization: Uses bincode for efficient and compact save files
  • Persistent Game State: Complete serialization of game state, players, zones, and commander data
  • Game Replays: Support for replaying games from any saved point
  • Automatic Saving: Configurable auto-save during state-based action checks
  • Save Metadata: Tracking of available save files with timestamps and information
  • Entity Mapping: Handles Bevy's entity references through save/load cycles

Use Cases

  • Save games for later continuation
  • Create game checkpoints before critical decisions
  • Analyze past games through replay functionality
  • Debug and testing of complex game states
  • Share interesting game states with other players

Components

The save/load system consists of several interrelated components:

  1. Plugin - The SaveLoadPlugin managing initialization and registration
  2. Events - Events for triggering save, load, and replay functionality
  3. Resources - Configuration and state tracking resources
  4. Data Structures - Serializable representations of game data
  5. Systems - Bevy systems for handling save/load operations and replay

Getting Started

See the Overview for a quick introduction to using the save/load system, or dive directly into the Implementation Details for more technical information.

For existing projects, the Integration Guide explains how to incorporate save/load functionality into your game systems.

Save/Load System Overview

This document provides a high-level overview of the save/load system in Rummage, explaining its core concepts and usage.

Basic Usage

Saving a Game

To save the current game state, send a SaveGameEvent:

#![allow(unused)]
fn main() {
// Save to a specific slot
world.send_event(SaveGameEvent {
    slot_name: "my_save".to_string(),
});
}

The save system will:

  1. Collect all necessary game state information
  2. Serialize game zones, cards, and commanders
  3. Use bevy_persistent to persist the game state
  4. Write it to the designated save file
  5. Update metadata with information about the save

Loading a Game

To load a previously saved game, send a LoadGameEvent:

#![allow(unused)]
fn main() {
// Load from a specific slot
world.send_event(LoadGameEvent {
    slot_name: "my_save".to_string(),
});
}

The load system will:

  1. Use bevy_persistent to load the saved game state
  2. Deserialize the game state data
  3. Recreate all necessary entities and resources
  4. Restore the game state, zone contents, and commander data
  5. Restore all entity relationships and card positions

Automatic Saving

The system includes an automatic save feature that triggers during state-based action checks:

#![allow(unused)]
fn main() {
// Configure auto-save behavior
commands.insert_resource(SaveConfig {
    save_directory: PathBuf::from("saves"),
    auto_save_enabled: true,
    auto_save_frequency: 10, // Save every 10 state-based action checks
});
}

Saved Data

The save system captures and restores the following data:

  1. Game State: Turn number, active player, priority holder, turn order, etc.
  2. Player Data: Life totals, mana pools, and other player-specific information
  3. Zone Data: Contents of all game zones (libraries, hands, battlefield, graveyard, etc.)
  4. Card Positions: Where each card is located in the game state
  5. Commander Information: Commander assignments, cast counts, and zone locations

Replay Functionality

The replay system allows stepping through a saved game:

#![allow(unused)]
fn main() {
// Start a replay from a save file
world.send_event(StartReplayEvent {
    slot_name: "my_save".to_string(),
});

// Step forward in the replay (multiple steps possible)
world.send_event(StepReplayEvent { steps: 1 });

// Stop the current replay
world.send_event(StopReplayEvent);
}

During replay, the system:

  1. Loads the initial game state
  2. Applies recorded actions in sequence
  3. Updates the visual state of the game
  4. Allows stepping forward at the user's pace

Save Metadata

The system maintains metadata about all saves in the SaveMetadata resource using bevy_persistent:

#![allow(unused)]
fn main() {
// Access save metadata
fn display_saves_system(save_metadata: Res<Persistent<SaveMetadata>>) {
    for save in &save_metadata.saves {
        println!("Save: {}, Turn: {}, Time: {}", 
            save.slot_name, 
            save.turn_number, 
            save.timestamp);
    }
}
}

Configuration

The save system can be configured via the SaveConfig resource:

#![allow(unused)]
fn main() {
let config = SaveConfig {
    // Directory where save files are stored
    save_directory: PathBuf::from("custom_saves"),
    
    // Whether auto-save is enabled
    auto_save_enabled: true,
    
    // How often auto-saves occur (in state-based action checks)
    auto_save_frequency: 20,
};
}

Entity Serialization

The save/load system handles entity references by converting them to indices during serialization and rebuilding entities during deserialization. This preserves all relationships between entities despite the fact that entity IDs will change between sessions.

Visual Differential Testing

The save/load system includes built-in support for visual differential testing through integration with the snapshot system. This allows for:

  1. Visual Regression Testing: Compare game renders at different points in time to detect unintended visual changes
  2. State Verification: Visually confirm that game states are correctly preserved and restored
  3. Replay Validation: Ensure that replays accurately reproduce the original game visually

Key Features

  • Automatic Captures: Images are automatically captured during save operations and replay steps
  • Manual Triggers: Press F10 during replay to capture the current visual state
  • Programmatic API: Functions to capture and compare game states from code
  • Metadata Tracking: Each snapshot is tagged with save slot, turn number, and timestamps

Example Usage

Visual differential testing can be used in both manual testing and automated tests:

#![allow(unused)]
fn main() {
// In automated tests
let result = run_visual_diff_test("test_save_game");
assert!(result.is_ok(), "Visual differences detected");

// In manual testing, press F10 during replay to take snapshots
// at points of interest, then compare the resulting images
}

Next Steps

Save/Load System Implementation

This document provides technical details about the save/load system implementation in Rummage.

Architecture

The save/load system consists of several interconnected components:

  1. Plugin: SaveLoadPlugin handles registration of all events, resources, and systems.
  2. Events: Events like SaveGameEvent and LoadGameEvent trigger save and load operations.
  3. Resources: Configuration and state tracking resources like SaveConfig and ReplayState.
  4. Data Structures: Serializable data representations in the data.rs module.
  5. Systems: Bevy systems for handling operations defined in systems.rs.

Data Model

The save/load system uses a comprehensive data model to capture the game state:

GameSaveData

The main structure that holds all serialized game data:

#![allow(unused)]
fn main() {
pub struct GameSaveData {
    pub game_state: GameStateData,
    pub players: Vec<PlayerData>,
    pub zones: ZoneData,
    pub commanders: CommanderData,
    pub save_version: String,
}
}

GameStateData

Core game state information:

#![allow(unused)]
fn main() {
pub struct GameStateData {
    pub turn_number: u32,
    pub active_player_index: usize,
    pub priority_holder_index: usize,
    pub turn_order_indices: Vec<usize>,
    pub lands_played: Vec<(usize, u32)>,
    pub main_phase_action_taken: bool,
    pub drawn_this_turn: Vec<usize>,
    pub eliminated_players: Vec<usize>,
    pub use_commander_damage: bool,
    pub commander_damage_threshold: u32,
    pub starting_life: i32,
}
}

PlayerData

Player-specific information:

#![allow(unused)]
fn main() {
pub struct PlayerData {
    pub id: usize,
    pub name: String,
    pub life: i32,
    pub mana_pool: ManaPool,
    pub player_index: usize,
}
}

ZoneData

Information about card zones and contents:

#![allow(unused)]
fn main() {
pub struct ZoneData {
    // Maps player indices to their libraries
    pub libraries: std::collections::HashMap<usize, Vec<usize>>,
    // Maps player indices to their hands
    pub hands: std::collections::HashMap<usize, Vec<usize>>,
    // Shared battlefield
    pub battlefield: Vec<usize>,
    // Maps player indices to their graveyards
    pub graveyards: std::collections::HashMap<usize, Vec<usize>>,
    // Shared exile zone
    pub exile: Vec<usize>,
    // Command zone
    pub command_zone: Vec<usize>,
    // Maps card indices to their current zone
    pub card_zone_map: std::collections::HashMap<usize, Zone>,
}
}

CommanderData

Commander-specific data:

#![allow(unused)]
fn main() {
pub struct CommanderData {
    // Maps player indices to their commander indices
    pub player_commanders: std::collections::HashMap<usize, Vec<usize>>,
    // Maps commander indices to their current zone
    pub commander_zone_status: std::collections::HashMap<usize, CommanderZoneLocation>,
    // Tracks how many times a commander has moved zones
    pub zone_transition_count: std::collections::HashMap<usize, u32>,
}
}

bevy_persistent Integration

The save/load system uses bevy_persistent for robust persistence. This implementation provides:

  1. Format Selection: Currently uses Bincode for efficient binary serialization.
  2. Path Selection: Appropriate paths based on platform (native or web) and user configuration.
  3. Error Handling: Robust handling of failures during save/load operations with graceful fallbacks.
  4. Resource Management: Automatic resource persistence and loading.

Example integration from the setup_save_system function with improved error handling:

#![allow(unused)]
fn main() {
// Create save directory if it doesn't exist
let config = SaveConfig::default();

// Only try to create directory on native platforms
#[cfg(not(target_arch = "wasm32"))]
if let Err(e) = std::fs::create_dir_all(&config.save_directory) {
    error!("Failed to create save directory: {}", e);
    // Continue anyway - the directory might already exist
}

// Determine the appropriate base path for persistence based on platform
let metadata_path = get_storage_path(&config, "metadata.bin");

// Initialize persistent save metadata with fallback options
let save_metadata = match Persistent::builder()
    .name("save_metadata")
    .format(StorageFormat::Bincode)
    .path(metadata_path)
    .default(SaveMetadata::default())
    .build()
{
    Ok(metadata) => metadata,
    Err(e) => {
        error!("Failed to create persistent save metadata: {}", e);
        // Create a fallback in-memory resource
        Persistent::builder()
            .name("save_metadata")
            .format(StorageFormat::Bincode)
            .path(PathBuf::from("metadata.bin"))
            .default(SaveMetadata::default())
            .build()
            .expect("Failed to create even basic metadata")
    }
};

commands.insert_resource(config.clone());
commands.insert_resource(save_metadata);
}

Configuration

The save system is configured through the SaveConfig resource:

#![allow(unused)]
fn main() {
#[derive(Resource, Clone, Debug)]
pub struct SaveConfig {
    pub save_directory: PathBuf,
    pub auto_save_enabled: bool,
    pub auto_save_frequency: usize,
}
}

This resource allows customizing:

  • The directory where save files are stored
  • Whether auto-saving is enabled
  • How frequently auto-saves occur

Entity Mapping

One of the challenges in serializing Bevy's ECS is handling entity references. The save/load system solves this by:

  1. During Save: Converting entity references to indices using a mapping
  2. During Load: Recreating entities and building a reverse mapping
  3. After Load: Reconstructing relationships using the new entity handles

This approach ensures entity references remain valid across save/load cycles, even though the actual entity IDs change.

Replay System

The replay system extends save/load functionality by:

  1. Loading a saved game state
  2. Recording actions in a ReplayAction queue
  3. Allowing step-by-step playback of recorded actions
  4. Providing controls to start, step through, and stop replays

Error Handling

The save/load system employs several error handling strategies:

  1. Corrupted Data: Graceful handling of corrupted saves with fallbacks to default values
  2. Missing Entities: Safe handling when mapped entities don't exist, including placeholder entities when needed
  3. Empty Player Lists: Special handling for saves with no players, preserving game state data
  4. Version Compatibility: Checking save version compatibility
  5. File System Errors: Robust handling of IO and persistence errors with appropriate error messages
  6. Directory Creation: Automatic creation of save directories with error handling and verification
  7. Save Verification: Verification that save files were actually created with appropriate delays
  8. Filesystem Synchronization: Added delays to ensure filesystem operations complete before verification

Example of handling corrupted entity mappings:

#![allow(unused)]
fn main() {
// If there's a corrupted mapping, fall back to basic properties
if index_to_entity.is_empty() || index_to_entity.contains(&Entity::PLACEHOLDER) {
    // At minimum, restore basic properties not tied to player entities
    game_state.turn_number = save_data.game_state.turn_number;
    
    // For empty player list, set reasonable defaults for player-related fields
    if save_data.game_state.turn_order_indices.is_empty() {
        // Create a fallback turn order
        game_state.turn_order = VecDeque::new();
    }
} else {
    // Full restore with valid player entities
    **game_state = save_data.to_game_state(&index_to_entity);
}
}

Example of improved directory creation and save verification:

#![allow(unused)]
fn main() {
// Ensure save directory exists for native platforms
#[cfg(not(target_arch = "wasm32"))]
{
    if !config.save_directory.exists() {
        match std::fs::create_dir_all(&config.save_directory) {
            Ok(_) => info!("Created save directory: {:?}", config.save_directory),
            Err(e) => {
                error!("Failed to create save directory: {}", e);
                continue; // Skip this save attempt
            }
        }
    }
}

// ... saving process ...

// Verify save file was created for native platforms
#[cfg(not(target_arch = "wasm32"))]
{
    // Wait a short time to ensure filesystem operations complete
    std::thread::sleep(std::time::Duration::from_millis(100));
    
    if !save_path.exists() {
        error!("Save file was not created at: {:?}", save_path);
        continue;
    } else {
        info!("Verified save file exists at: {:?}", save_path);
    }
}
}

Testing

The save/load system includes comprehensive tests:

  1. Unit Tests: Testing individual components and functions
  2. Integration Tests: Testing full save/load cycles
  3. Edge Cases: Testing corrupted saves, empty data, etc.
  4. Platform-Specific Tests: Special considerations for WebAssembly

WebAssembly Support

For web builds, the save/load system:

  1. Uses browser local storage instead of the file system
  2. Handles storage limitations and permissions
  3. Uses appropriate path prefixes for the storage backend

See WebAssembly Local Storage for more details.

Performance Considerations

The save/load system is designed with performance in mind:

  1. Uses efficient binary serialization (Bincode)
  2. Avoids unnecessary re-serialization of unchanged data
  3. Performs heavy operations outside of critical game loops
  4. Uses compact data representations where possible

Future Improvements

Potential future enhancements:

  1. Incremental Saves: Only saving changes since the last save
  2. Save Compression: Optional compression for large save files
  3. Save Verification: Checksums or other validation of save integrity
  4. Multiple Save Formats: Support for JSON or other human-readable formats
  5. Cloud Integration: Syncing saves to cloud storage

Integration with Snapshot System

The save/load system is integrated with the snapshot system to enable visual differential testing of game states at different points in time or different steps in a replay.

Visual Differential Testing

Visual differential testing allows capturing renderings of a game state at specific points in a saved game or replay. These images can be compared to detect visual differences, regressions, or unexpected changes in the game's rendering.

Automatic Snapshots

The snapshot system automatically captures screenshots when:

  • A game is saved (via take_save_game_snapshot system)
  • During replay, when steps are taken (via take_replay_snapshot system)

Manual Differential Testing

For more controlled testing, you can:

  1. Start a replay of a save file
  2. Press F10 at any point to capture the current state (via capture_replay_at_point system)
  3. Continue stepping through the replay
  4. Press F10 again to capture another state for comparison

Programmatic Differential Testing

The capture_differential_game_snapshot and compare_game_states functions provide a programmatic way to perform visual differential testing:

#![allow(unused)]
fn main() {
// Compare turn 1 to turn 3 of a saved game
let result = compare_game_states(
    &mut world,
    "my_save_game",
    (Some(1), None),  // Turn 1
    (Some(3), None),  // Turn 3
);

match result {
    Some((reference_image, comparison_image, difference)) => {
        // Compare the images or save them for later comparison
        if difference > THRESHOLD {
            println!("Visual difference detected: {}%", difference * 100.0);
        }
    },
    None => println!("Failed to capture comparison"),
}
}

SaveGameSnapshot Component

The integration uses the SaveGameSnapshot component to link snapshots to specific saved games:

#![allow(unused)]
fn main() {
pub struct SaveGameSnapshot {
    /// The save slot name this snapshot is associated with
    pub slot_name: String,

    /// The turn number in the saved game
    pub turn_number: u32,

    /// Optional timestamp of when the snapshot was taken
    pub timestamp: Option<i64>,

    /// Optional description of the game state
    pub description: Option<String>,
}
}

Test Systems

The integration includes:

  • Integration tests that verify snapshot events are triggered during save/load operations
  • Functions to capture snapshots at specific points in a replay
  • Comparison functions to detect visual differences between game states

Integration with Networking

Persistent Storage

WebAssembly Local Storage Support

The save/load system is designed to work seamlessly in both native desktop applications and WebAssembly environments running in a web browser.

How It Works

When your game runs in a web browser via WebAssembly, there is no traditional filesystem available. Instead, the save/load system automatically switches to using the browser's Local Storage API. This is done transparently, so your game code doesn't need to handle platform differences.

Path Handling

Path handling is abstracted to work across different platforms:

  • Native Desktop: Saves are stored in the saves/ directory relative to the game executable
  • WebAssembly: Saves are stored in the browser's local storage with keys prefixed with saves/

This is implemented using bevy-persistent's storage backend system, where paths starting with /local/ are mapped to browser local storage.

Storage Limitations

When running in WebAssembly, be aware of these limitations:

  1. Storage Size: Browsers typically limit local storage to 5-10MB total. This means all your saves combined should stay under this limit.

  2. Persistence: Unlike files on a desktop computer, local storage can be cleared if:

    • The user clears their browser data/cache
    • The browser decides to free up space
    • The user is in private/incognito mode (storage will be temporary)
  3. Serialization Format: The system uses bincode serialization for efficiency, which works well for both platforms but produces binary data. In the browser, this binary data is base64-encoded to be stored in local storage.

Debugging WebAssembly Storage

To inspect saved game data in a browser:

  1. Open your browser's developer tools (F12 or right-click > Inspect)
  2. Go to the "Application" tab (Chrome) or "Storage" tab (Firefox)
  3. Look for "Local Storage" in the left panel
  4. Select your site's domain
  5. Look for entries with keys starting with saves/

Implementation Details

The path handling is implemented through the get_storage_path helper function, which conditionally compiles different paths based on the target platform:

#![allow(unused)]
fn main() {
fn get_storage_path(filename: &str) -> PathBuf {
    #[cfg(target_arch = "wasm32")]
    {
        // For WebAssembly, use local storage with a prefix
        Path::new("/local/saves").join(filename)
    }
    
    #[cfg(not(target_arch = "wasm32"))]
    {
        // For native platforms, use the filesystem
        Path::new("saves").join(filename)
    }
}
}

This ensures that save files are automatically directed to the appropriate storage system based on the platform.

Testing

API Reference