cardinal-kernel 0.1.1

Headless, deterministic rules engine for turn-based, TCG-like games.
Documentation
use crate::{
    error::{EngineError, LegalityError},
    ids::PlayerId,
    model::action::Action,
    model::event::Event,
    rules::schema::Ruleset,
    state::gamestate::GameState,
    engine::scripting::RhaiEngine,
};

pub struct GameEngine {
    pub rules: Ruleset,
    pub state: GameState,
    pub cards: crate::engine::cards::CardRegistry,
    pub scripting: RhaiEngine,
    seed: u64,
    next_choice_id: u32,
    next_stack_id: u32,
}

pub struct StepResult {
    pub events: Vec<Event>,
}

impl GameEngine {
    pub fn new(rules: Ruleset, seed: u64, initial_state: GameState) -> Self {
        let cards = crate::engine::cards::build_registry(&rules.cards);
        let scripting = RhaiEngine::new();
        Self { rules, state: initial_state, cards, scripting, seed, next_choice_id: 1, next_stack_id: 1 }
    }

    /// Build a GameEngine directly from a `Ruleset`. This will create a minimal GameState
    /// via `GameState::from_ruleset`.
    pub fn from_ruleset(rules: Ruleset, seed: u64) -> Self {
        let initial = GameState::from_ruleset(&rules);
        let cards = crate::engine::cards::build_registry(&rules.cards);
        let scripting = RhaiEngine::new();
        
        // Note: Script loading from files is intentionally NOT done here to maintain
        // determinism in the core engine. Scripts should be loaded via a separate
        // initialization step at a higher level (e.g., in cardinal-cli or a web frontend).
        // This keeps file I/O out of the engine core.
        
        Self { rules, state: initial, cards, scripting, seed, next_choice_id: 1, next_stack_id: 1 }
    }

    pub fn legal_actions(&self, _player: PlayerId) -> Vec<Action> {
        // Start simple: implement legality later in engine/legality.rs
        // Return only actions that make sense (PassPriority, PlayCard if allowed, etc).
        vec![Action::PassPriority]
    }

    /// Generate the next unique stack item ID
    pub fn next_stack_id(&mut self) -> u32 {
        let id = self.next_stack_id;
        self.next_stack_id += 1;
        id
    }

    /// Generate the next unique choice ID
    pub fn next_choice_id(&mut self) -> u32 {
        let id = self.next_choice_id;
        self.next_choice_id += 1;
        id
    }

    pub fn apply_action(&mut self, player: PlayerId, action: Action) -> Result<StepResult, EngineError> {
        // 1) validate
        self.validate_action(player, &action)?;

        // 2) apply (reducer)
        let mut events = crate::engine::reducer::apply(self, player, action)?;

        // 3) post-step checks (win/loss, auto-resolve stack, advance phase)
        
        // Check for win/loss conditions
        self.check_game_end(&mut events);
        
        // Auto-resolve stack if it has items and no pending choice
        self.auto_resolve_stack(&mut events);
        
        // Advance to next phase/step if appropriate
        self.advance_phase_if_ready(&mut events);

        Ok(StepResult { events })
    }

    fn check_game_end(&mut self, events: &mut Vec<Event>) {
        // Check if any player has <= 0 life (loses)
        let losers: Vec<PlayerId> = self.state.players.iter()
            .filter(|p| p.life <= 0)
            .map(|p| p.id)
            .collect();

        if !losers.is_empty() {
            // Determine winner: last player with > 0 life
            let winner = self.state.players.iter()
                .find(|p| p.life > 0)
                .map(|p| p.id);
            
            self.state.ended = Some(crate::state::gamestate::GameEnd {
                winner,
                reason: "Life total reached 0".to_string(),
            });
            events.push(Event::GameEnded { winner, reason: "Life total reached 0".to_string() });
        }
    }

    fn auto_resolve_stack(&mut self, events: &mut Vec<Event>) {
        // If the stack has items and there's no pending choice, resolve the top item
        while !self.state.stack.is_empty() && self.state.pending_choice.is_none() {
            if let Some(item) = self.state.stack.pop() {
                let item_id = item.id;
                
                // Execute the effect and apply resulting commands
                match crate::engine::effect_executor::execute_effect(
                    &item.effect,
                    item.source,
                    item.controller,
                    &self.state,
                    Some(&self.scripting),
                ) {
                    Ok(commands) => {
                        // Apply the commands and collect their events
                        let effect_events = crate::engine::events::commit_commands(&mut self.state, &commands);
                        events.extend(effect_events);
                    }
                    Err(_err) => {
                        // Effect execution failed; silently continue resolving the stack.
                        // Future: emit a dedicated Event to report the failure to callers
                    }
                }
                
                // Emit StackResolved event after executing effect
                events.push(Event::StackResolved { item_id });
            }
        }
    }

    fn advance_phase_if_ready(&mut self, events: &mut Vec<Event>) {
        // Phase advancement logic with priority system:
        // 1. Only advance if stack is empty and no pending choices
        // 2. Check if all players have passed priority
        // 3. If all passed: resolve stack items, reset passes, advance phase
        // 4. If not all passed: don't advance yet, wait for more passes
        
        if !self.state.stack.is_empty() || self.state.pending_choice.is_some() {
            // Can't advance while stack has items or choice is pending
            return;
        }

        let num_players = self.state.players.len() as u32;
        
        // Check if all players have passed priority
        if self.state.turn.priority_passes < num_players {
            // Not all players have passed yet, don't advance
            return;
        }
        
        // All players have passed! Reset priority counter and give priority to active player
        self.state.turn.priority_passes = 0;
        self.state.turn.priority_player = self.state.turn.active_player;

        // Find current phase index
        let current_phase_idx = self.rules.turn.phases.iter()
            .position(|p| p.id.as_str() == self.state.turn.phase.0)
            .unwrap_or(0);
        let current_phase = &self.rules.turn.phases[current_phase_idx];

        // Find current step index within current phase
        let current_step_idx = current_phase.steps.iter()
            .position(|s| s.id.as_str() == self.state.turn.step.0)
            .unwrap_or(0);

        // Try to advance to next step in current phase
        if current_step_idx + 1 < current_phase.steps.len() {
            let next_step = &current_phase.steps[current_step_idx + 1];
            let step_box: Box<str> = next_step.id.clone().into_boxed_str();
            let step_static: &'static str = Box::leak(step_box);
            self.state.turn.step = crate::ids::StepId(step_static);
            events.push(Event::PhaseAdvanced {
                phase: self.state.turn.phase.clone(),
                step: self.state.turn.step.clone(),
            });
            return;
        }

        // No more steps in current phase; advance to next phase
        if current_phase_idx + 1 < self.rules.turn.phases.len() {
            let next_phase = &self.rules.turn.phases[current_phase_idx + 1];
            let phase_box: Box<str> = next_phase.id.clone().into_boxed_str();
            let phase_static: &'static str = Box::leak(phase_box);
            self.state.turn.phase = crate::ids::PhaseId(phase_static);

            // Start at first step of new phase
            if let Some(first_step) = next_phase.steps.first() {
                let step_box: Box<str> = first_step.id.clone().into_boxed_str();
                let step_static: &'static str = Box::leak(step_box);
                self.state.turn.step = crate::ids::StepId(step_static);
            } else {
                self.state.turn.step = crate::ids::StepId("start");
            }

            events.push(Event::PhaseAdvanced {
                phase: self.state.turn.phase.clone(),
                step: self.state.turn.step.clone(),
            });
            return;
        }

        // End of turn: cycle back to first phase and advance turn number
        if let Some(first_phase) = self.rules.turn.phases.first() {
            let phase_box: Box<str> = first_phase.id.clone().into_boxed_str();
            let phase_static: &'static str = Box::leak(phase_box);
            self.state.turn.phase = crate::ids::PhaseId(phase_static);

            if let Some(first_step) = first_phase.steps.first() {
                let step_box: Box<str> = first_step.id.clone().into_boxed_str();
                let step_static: &'static str = Box::leak(step_box);
                self.state.turn.step = crate::ids::StepId(step_static);
            } else {
                self.state.turn.step = crate::ids::StepId("start");
            }

            self.state.turn.number += 1;

            // Rotate active player and give them priority
            let next_player_idx = (self.state.turn.active_player.0 + 1) % num_players as u8;
            self.state.turn.active_player = crate::ids::PlayerId(next_player_idx);
            self.state.turn.priority_player = self.state.turn.active_player;

            events.push(Event::PhaseAdvanced {
                phase: self.state.turn.phase.clone(),
                step: self.state.turn.step.clone(),
            });
        }
    }

    fn validate_action(&self, player: PlayerId, action: &Action) -> Result<(), LegalityError> {
        crate::engine::legality::validate(self, player, action)
    }
}