liquidwar7core 0.2.0

Liquidwar7 core logic library, low-level things which are game-engine agnostic.
Documentation
// Copyright (C) 2025 Christian Mauduit <ufoot@ufoot.org>

//! String representation utilities for World.
//!
//! This module provides functions to convert World objects into human-readable
//! ASCII art strings, useful for debugging, testing, and visualization.

use shortestpath::Gradient;

use crate::{Cursor, Team, World};

/// Represents a world mesh as a string.
///
/// Converts a World's QuadMesh into ASCII art where:
/// - `.` represents free/walkable cells
/// - `#` represents wall/blocked cells
///
/// Shows the z=0 layer only. Each row ends with a newline character.
pub fn repr_world_mesh(world: &World) -> String {
    let mesh = world.mesh();
    let (width, height, _depth) = mesh.dimensions();
    let mut output = String::new();

    for y in 0..height {
        for x in 0..width {
            // If cell_at returns Some, the cell exists and is walkable
            // If it returns None, this coordinate is a wall
            let c = if mesh.cell_at(x, y, 0).is_some() { '.' } else { '#' };
            output.push(c);
        }
        output.push('\n');
    }
    output
}

/// Represents a world mesh with a team's gradient as a string.
///
/// Converts a World's mesh into ASCII art showing distances from the team's
/// gradient. Each cell shows:
/// - `0-9` representing distance modulo 10
/// - `?` for unreachable cells (infinite distance)
/// - `#` for wall/blocked cells
///
/// # Arguments
///
/// * `world` - The world to represent
/// * `team` - The team whose gradient to display
pub fn repr_world_with_team_gradient(world: &World, team: &Team) -> String {
    repr_world_with_gradient(world, team.gradient())
}

/// Represents a world mesh with a custom gradient as a string.
///
/// Similar to `repr_world_with_team_gradient` but accepts any gradient,
/// not just one from a team.
///
/// # Arguments
///
/// * `world` - The world to represent
/// * `gradient` - The gradient to display
pub fn repr_world_with_gradient(world: &World, gradient: &Gradient) -> String {
    let mesh = world.mesh();
    let (width, height, _depth) = mesh.dimensions();
    let mut output = String::new();

    for y in 0..height {
        for x in 0..width {
            let c = match mesh.cell_at(x, y, 0) {
                Some(idx) => {
                    let dist = gradient.get_distance(idx);
                    if dist.is_infinite() || dist > 1e10 {
                        '?'
                    } else {
                        let digit = (dist as usize) % 10;
                        char::from_digit(digit as u32, 10).unwrap_or('?')
                    }
                }
                None => '#',
            };
            output.push(c);
        }
        output.push('\n');
    }
    output
}

/// Represents a world with its armies as a string.
///
/// Converts a World into ASCII art showing fighters on the battlefield:
/// - Fighters are shown as the first letter of their team name
/// - UPPERCASE if health > 0.5
/// - lowercase if health <= 0.5
/// - `.` represents empty walkable cells
/// - `#` represents wall/blocked cells
pub fn repr_world_armies(world: &World) -> String {
    let (width, height, _depth) = world.dimensions();
    let mut output = String::new();

    for y in 0..height {
        for x in 0..width {
            let c = fighter_char_at(world, x, y, 0);
            output.push(c);
        }
        output.push('\n');
    }
    output
}

/// Represents a world with armies and cursors as a string.
///
/// Converts a World into ASCII art showing both fighters and cursors:
/// - Cursors are shown as Greek letters (α, β, γ...) corresponding to team name's first letter
/// - Fighters are shown as the first letter of their team name (uppercase if health > 0.5)
/// - Cursors take precedence over fighters when both occupy the same cell
/// - `.` represents empty walkable cells
/// - `#` represents wall/blocked cells
pub fn repr_world_armies_and_cursors(world: &World) -> String {
    let (width, height, _depth) = world.dimensions();
    let mut output = String::new();

    for y in 0..height {
        for x in 0..width {
            // Check for cursor first (cursors take precedence)
            let c = if let Some(cursor_char) = cursor_char_at(world, x, y) {
                cursor_char
            } else {
                fighter_char_at(world, x, y, 0)
            };
            output.push(c);
        }
        output.push('\n');
    }
    output
}

/// Helper function to get the character representing a fighter at a given cell.
///
/// Returns:
/// - First letter of team name (uppercase if health > 0.5, lowercase otherwise)
/// - '.' if cell is walkable but empty
/// - '#' if cell is a wall
/// - '?' if fighter exists but team not found
fn fighter_char_at(world: &World, x: usize, y: usize, z: usize) -> char {
    // Check walkability via QuadMesh
    if !world.is_walkable(x, y, z) {
        return '#'; // Wall
    }

    // Check for fighter via Grid
    let Some(fighter_id) = world.grid().fighter_at(x, y, z) else {
        return '.'; // Empty walkable cell
    };

    let Some(fighter) = world.armies().get_fighter(&fighter_id) else {
        return '.'; // Fighter not found (shouldn't happen)
    };

    let Some(team) = world.get_team(&fighter.team_id) else {
        return '?'; // Team not found
    };

    let first_char = team.name().chars().next().unwrap_or('?');

    if fighter.health > 0.5 {
        first_char.to_ascii_uppercase()
    } else {
        first_char.to_ascii_lowercase()
    }
}

/// Maps a Latin letter (A-Z) to its corresponding Greek letter.
///
/// The mapping is phonetic where possible:
/// A→α, B→β, G→γ, D→δ, E→ε, Z→ζ, H→η, etc.
///
/// Letters without a clear Greek equivalent map to '?'.
fn latin_to_greek(c: char) -> char {
    // Phonetic mapping: Latin letter -> Greek equivalent
    // A→α, B→β, C→γ(g-sound), D→δ, E→ε, F→φ, G→γ, H→η, I→ι, J→?, K→κ, L→λ, M→μ,
    // N→ν, O→ο, P→π, Q→?, R→ρ, S→σ, T→τ, U→υ, V→?, W→ω, X→ξ, Y→ψ, Z→ζ
    const GREEK_MAP: [char; 26] = [
        'α', // A
        'β', // B
        'γ', // C (using gamma, c can sound like g)
        'δ', // D
        'ε', // E
        'φ', // F (phi makes f sound)
        'γ', // G
        'η', // H (eta)
        'ι', // I
        '?', // J (no Greek equivalent)
        'κ', // K
        'λ', // L
        'μ', // M
        'ν', // N
        'ο', // O
        'π', // P
        '?', // Q (no Greek equivalent)
        'ρ', // R
        'σ', // S
        'τ', // T
        'υ', // U
        '?', // V (no Greek equivalent)
        'ω', // W (omega)
        'ξ', // X
        'ψ', // Y (psi)
        'ζ', // Z
    ];

    let upper = c.to_ascii_uppercase();
    if upper.is_ascii_uppercase() {
        let index = (upper as u8 - b'A') as usize;
        GREEK_MAP[index]
    } else {
        '?'
    }
}

/// Helper function to check if there's a cursor at the given cell coordinates.
///
/// Returns the Greek letter for the cursor's team if found, None otherwise.
fn cursor_char_at(world: &World, cell_x: usize, cell_y: usize) -> Option<char> {
    for (_, cursor) in world.cursors() {
        let cursor_cell_x = cursor.x().floor() as usize;
        let cursor_cell_y = cursor.y().floor() as usize;

        if cursor_cell_x == cell_x && cursor_cell_y == cell_y {
            if let Some(team) = world.get_team(&cursor.team_id()) {
                let first_char = team.name().chars().next().unwrap_or('?');
                return Some(latin_to_greek(first_char));
            }
            return Some('?');
        }
    }
    None
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::quad_mesh::QuadMesh;
    use crate::{BLUE, RED};

    fn create_test_world(width: usize, height: usize) -> World {
        let gradient_mesh = QuadMesh::new(width, height, 1, |_, _, _| true).unwrap();
        World::new(gradient_mesh)
    }

    fn create_test_world_with_walls<F: Fn(usize, usize, usize) -> bool>(
        width: usize,
        height: usize,
        is_passable: F,
    ) -> World {
        let gradient_mesh = QuadMesh::new(width, height, 1, &is_passable).unwrap();
        World::new(gradient_mesh)
    }

    #[test]
    fn test_repr_world_mesh_full() {
        let world = create_test_world(5, 3);
        let repr = repr_world_mesh(&world);
        assert_eq!(repr, ".....\n.....\n.....\n");
    }

    #[test]
    fn test_repr_world_mesh_with_walls() {
        let world = create_test_world_with_walls(3, 3, |x, y, _| !(x == 1 && (y == 0 || y == 2)));
        let repr = repr_world_mesh(&world);
        assert_eq!(repr, ".#.\n...\n.#.\n");
    }

    #[test]
    fn test_repr_world_with_team_gradient_uncomputed() {
        let mut world = create_test_world(3, 3);
        let team_id = world.add_team(RED, "Red".to_string());
        let team = world.get_team(&team_id).unwrap();

        let repr = repr_world_with_team_gradient(&world, team);
        // Gradient not computed, all cells show '?'
        assert_eq!(repr, "???\n???\n???\n");
    }

    #[test]
    fn test_repr_world_armies_empty() {
        let world = create_test_world(3, 3);
        let repr = repr_world_armies(&world);
        assert_eq!(repr, "...\n...\n...\n");
    }

    #[test]
    fn test_repr_world_armies_with_fighter() {
        let mut world = create_test_world(3, 3);
        let team_id = world.add_team(RED, "Red".to_string());
        world.spawn_fighter(team_id, 0, 0, 0).expect("Failed to spawn fighter");

        let repr = repr_world_armies(&world);
        assert!(repr.starts_with('R'), "Expected 'R' at start, got: {}", repr);
    }

    #[test]
    fn test_repr_world_armies_multiple_teams() {
        let mut world = create_test_world(5, 1);

        let red_id = world.add_team(RED, "Red".to_string());
        let blue_id = world.add_team(BLUE, "Blue".to_string());

        world.spawn_fighter(red_id, 0, 0, 0).expect("Failed to spawn red");
        world.spawn_fighter(blue_id, 3, 0, 0).expect("Failed to spawn blue");

        let repr = repr_world_armies(&world);
        assert_eq!(repr, "R..B.\n");
    }

    #[test]
    fn test_repr_world_armies_health_affects_case() {
        let mut world = create_test_world(3, 1);
        let team_id = world.add_team(RED, "Red".to_string());

        // Spawn two fighters at (0,0,0) and (2,0,0)
        let healthy_id = world.spawn_fighter(team_id, 0, 0, 0).expect("Failed to spawn");
        let weak_id = world.spawn_fighter(team_id, 2, 0, 0).expect("Failed to spawn");

        // Set health: healthy > 0.5, weak <= 0.5
        world.armies_mut().get_fighter_mut(&healthy_id).unwrap().health = 0.8;
        world.armies_mut().get_fighter_mut(&weak_id).unwrap().health = 0.3;

        let repr = repr_world_armies(&world);
        assert_eq!(repr, "R.r\n");
    }

    #[test]
    fn test_latin_to_greek_mapping() {
        assert_eq!(latin_to_greek('A'), 'α');
        assert_eq!(latin_to_greek('B'), 'β');
        assert_eq!(latin_to_greek('G'), 'γ');
        assert_eq!(latin_to_greek('R'), 'ρ');
        assert_eq!(latin_to_greek('S'), 'σ');
        assert_eq!(latin_to_greek('X'), 'ξ');
        assert_eq!(latin_to_greek('Y'), 'ψ');
        assert_eq!(latin_to_greek('Z'), 'ζ');
        // Letters without clear Greek equivalent
        assert_eq!(latin_to_greek('J'), '?');
        assert_eq!(latin_to_greek('Q'), '?');
        assert_eq!(latin_to_greek('V'), '?');
        // Lowercase should work too
        assert_eq!(latin_to_greek('a'), 'α');
        assert_eq!(latin_to_greek('b'), 'β');
        // Non-letters
        assert_eq!(latin_to_greek('1'), '?');
    }

    #[test]
    fn test_repr_world_armies_and_cursors_with_cursor() {
        let mut world = create_test_world(5, 5);
        let team_id = world.add_team(RED, "Red".to_string());
        world.add_cursor(team_id);

        let repr = repr_world_armies_and_cursors(&world);
        // Cursor should appear as Greek rho (ρ) for 'R'
        assert!(repr.contains('ρ'), "Expected ρ in repr: {}", repr);
    }

    #[test]
    fn test_repr_world_armies_and_cursors_multiple_teams() {
        let mut world = create_test_world(10, 10);

        let red_id = world.add_team(RED, "Red".to_string());
        let blue_id = world.add_team(BLUE, "Blue".to_string());

        world.add_cursor(red_id);
        world.add_cursor(blue_id);

        let repr = repr_world_armies_and_cursors(&world);
        // Should have both cursors
        assert!(repr.contains('ρ'), "Expected ρ (Red) in repr: {}", repr);
        assert!(repr.contains('β'), "Expected β (Blue) in repr: {}", repr);
    }

    #[test]
    fn test_repr_world_armies_and_cursors_no_cursors() {
        let mut world = create_test_world(3, 3);
        let team_id = world.add_team(RED, "Red".to_string());
        // Spawn at center cell (1, 1, 0)
        world.spawn_fighter(team_id, 1, 1, 0).expect("Failed to spawn");

        let repr = repr_world_armies_and_cursors(&world);
        // Should just show fighter, no Greek letters
        assert!(repr.contains('R'), "Expected R in repr: {}", repr);
        assert!(!repr.contains('ρ'), "Should not contain cursor");
    }
}