diff --git a/src/zzt/elements.rs b/src/zzt/elements.rs index 4553f9a..3fc8016 100644 --- a/src/zzt/elements.rs +++ b/src/zzt/elements.rs @@ -217,6 +217,54 @@ impl Element { _ => None, } } + + /// Map alias name to parameter number (1, 2, or 3) based on element type. + pub fn alias_to_param(self, alias: &str) -> Option { + if self.p1_alias() == Some(alias) { + Some(1) + } else if self.p2_alias() == Some(alias) { + Some(2) + } else if self.p3_alias() == Some(alias) { + Some(3) + } else { + None + } + } +} + +/// Resolve alias name to parameter number, with optional element context. +/// Falls back to checking all known aliases if element is None. +pub fn resolve_alias(alias: &str, element: Option) -> Option { + // If we have an element, use its specific alias mapping + if let Some(elem) = element { + if let Some(param) = elem.alias_to_param(alias) { + return Some(param); + } + } + + // Fallback: check all known aliases + // p1 aliases + if matches!( + alias, + "char" | "sensitivity" | "intelligence" | "start_time" + ) { + return Some(1); + } + + // p2 aliases + if matches!( + alias, + "rate" | "period" | "resting_time" | "speed" | "firing_rate" | "deviance" + ) { + return Some(2); + } + + // p3 aliases + if matches!(alias, "destination" | "firing_type") { + return Some(3); + } + + None } /// Get the human-readable name for an element ID, or describe unknown elements. diff --git a/src/zzt/error.rs b/src/zzt/error.rs index a491bbd..dcce8b5 100644 --- a/src/zzt/error.rs +++ b/src/zzt/error.rs @@ -23,6 +23,12 @@ pub enum ParseError { #[error("parse error: {0}")] ParseError(String), + + #[error("text parse error: {message}")] + TextParseError { message: String }, + + #[error("invalid hex in terrain: {0}")] + InvalidHex(String), } impl NomParseError for ParseError { diff --git a/src/zzt/mod.rs b/src/zzt/mod.rs index 95b3268..cc7b79e 100644 --- a/src/zzt/mod.rs +++ b/src/zzt/mod.rs @@ -7,4 +7,4 @@ mod text; pub use encoding::{decode_multiline, decode_oneline, encode_multiline, encode_oneline}; pub use error::ParseError; pub use parse::{Board, Program, Stat, Tile, World}; -pub use text::{board_to_text, world_to_text}; +pub use text::{board_to_text, text_to_board, text_to_world, world_to_text}; diff --git a/src/zzt/text.rs b/src/zzt/text.rs index aa42cc6..69de195 100644 --- a/src/zzt/text.rs +++ b/src/zzt/text.rs @@ -1,6 +1,17 @@ use std::fmt::Write; -use super::elements::{Element, element_name}; +use nom::{ + IResult, Parser, + branch::alt, + bytes::complete::{tag, take_until, take_while, take_while1}, + character::complete::{char, digit1, line_ending, multispace0, multispace1, not_line_ending}, + combinator::{map, map_res, opt, recognize, value}, + multi::many0, + sequence::pair, +}; + +use super::elements::{Element, element_name, resolve_alias}; +use super::error::ParseError; use super::parse::{Board, Program, Stat, Tile, World}; /// Write key = value if value != default. @@ -214,3 +225,675 @@ fn write_param(output: &mut String, value: u8, generic_name: &str, alias: Option let name = alias.unwrap_or(generic_name); writeln!(output, "{} = {}", name, value).unwrap(); } + +// ============================================================================ +// Text Parsing +// ============================================================================ + +/// Parsed value from a key-value pair. +#[derive(Debug, Clone)] +enum Value { + Int(i64), + Bool(bool), + String(String), + StringArray(Vec), + Tuple2(u8, u8), + TripleQuotedString(String), +} + +/// Skip a comment (# to end of line). +fn comment(input: &str) -> IResult<&str, ()> { + value((), pair(char('#'), not_line_ending)).parse(input) +} + +/// Skip whitespace and comments. +fn ws(input: &str) -> IResult<&str, ()> { + value( + (), + many0(alt((value((), multispace1), comment))), + ).parse(input) +} + +/// Parse a section header like [world], [board 0], [stat 1]. +/// Returns the optional index. +fn section_header<'a>(name: &'a str) -> impl FnMut(&'a str) -> IResult<&'a str, Option> { + move |input: &'a str| { + let (input, _) = ws(input)?; + let (input, _) = char('[').parse(input)?; + let (input, _) = tag(name).parse(input)?; + let (input, idx) = opt((multispace1, map_res(digit1, |s: &str| s.parse()))) + .map(|opt| opt.map(|(_, n)| n)) + .parse(input)?; + let (input, _) = char(']').parse(input)?; + // Consume rest of line (including any comment) + let (input, _) = opt(pair(take_while(|c| c == ' ' || c == '\t'), comment)).parse(input)?; + let (input, _) = opt(line_ending).parse(input)?; + Ok((input, idx)) + } +} + +/// Parse an identifier (key name). +fn identifier(input: &str) -> IResult<&str, &str> { + take_while1(|c: char| c.is_alphanumeric() || c == '_').parse(input) +} + +/// Parse a quoted string value (handles escape sequences). +fn quoted_string(input: &str) -> IResult<&str, String> { + let (input, _) = char('"').parse(input)?; + let mut result = String::new(); + let mut chars = input.chars().peekable(); + let mut consumed = 0; + + loop { + match chars.next() { + Some('"') => { + consumed += 1; + break; + } + Some('\\') => { + consumed += 1; + match chars.next() { + Some('n') => { + result.push('\n'); + consumed += 1; + } + Some('r') => { + result.push('\r'); + consumed += 1; + } + Some('t') => { + result.push('\t'); + consumed += 1; + } + Some('\\') => { + result.push('\\'); + consumed += 1; + } + Some('"') => { + result.push('"'); + consumed += 1; + } + Some(c) => { + // Unknown escape, keep as-is + result.push('\\'); + result.push(c); + consumed += 1; + } + None => break, + } + } + Some(c) => { + result.push(c); + consumed += c.len_utf8(); + } + None => break, + } + } + + Ok((&input[consumed..], result)) +} + +/// Parse a triple-quoted string ("""..."""). +fn triple_quoted_string(input: &str) -> IResult<&str, String> { + let (input, _) = tag("\"\"\"").parse(input)?; + let (input, _) = opt(line_ending).parse(input)?; + let (input, content) = take_until("\"\"\"").parse(input)?; + let (input, _) = tag("\"\"\"").parse(input)?; + // Trim trailing newline from content if present + let content = content.strip_suffix('\n').unwrap_or(content); + Ok((input, content.to_string())) +} + +/// Parse an integer (possibly negative). +fn integer(input: &str) -> IResult<&str, i64> { + map_res( + recognize(pair(opt(char('-')), digit1)), + |s: &str| s.parse(), + ).parse(input) +} + +/// Parse a boolean value. +fn boolean(input: &str) -> IResult<&str, bool> { + alt((value(true, tag("true")), value(false, tag("false")))).parse(input) +} + +/// Parse a tuple like (element, color). +fn tuple2(input: &str) -> IResult<&str, (u8, u8)> { + let (input, _) = char('(').parse(input)?; + let (input, _) = multispace0.parse(input)?; + let (input, a) = map_res(digit1, |s: &str| s.parse::()).parse(input)?; + let (input, _) = multispace0.parse(input)?; + let (input, _) = char(',').parse(input)?; + let (input, _) = multispace0.parse(input)?; + let (input, b) = map_res(digit1, |s: &str| s.parse::()).parse(input)?; + let (input, _) = multispace0.parse(input)?; + let (input, _) = char(')').parse(input)?; + Ok((input, (a, b))) +} + +/// Parse a string array like ["foo", "bar", ""]. +fn string_array(input: &str) -> IResult<&str, Vec> { + let (input, _) = char('[').parse(input)?; + let (input, _) = multispace0.parse(input)?; + + let mut items = Vec::new(); + let mut input = input; + + // Check for empty array + if let Ok((next, _)) = char::<_, nom::error::Error<&str>>(']').parse(input) { + return Ok((next, items)); + } + + // Parse first item + let (next, first) = quoted_string(input)?; + items.push(first); + input = next; + + // Parse remaining items + loop { + let (next, _) = multispace0.parse(input)?; + if let Ok((next, _)) = char::<_, nom::error::Error<&str>>(']').parse(next) { + return Ok((next, items)); + } + let (next, _) = char(',').parse(next)?; + let (next, _) = multispace0.parse(next)?; + let (next, item) = quoted_string(next)?; + items.push(item); + input = next; + } +} + +/// Parse a value (any type). +fn parse_value(input: &str) -> IResult<&str, Value> { + alt(( + map(triple_quoted_string, Value::TripleQuotedString), + map(string_array, Value::StringArray), + map(quoted_string, Value::String), + map(tuple2, |(a, b)| Value::Tuple2(a, b)), + map(boolean, Value::Bool), + map(integer, Value::Int), + )).parse(input) +} + +/// Parse a key = value pair. +fn parse_key_value(input: &str) -> IResult<&str, (&str, Value)> { + let (input, _) = ws(input)?; + let (input, key) = identifier(input)?; + let (input, _) = multispace0.parse(input)?; + let (input, _) = char('=').parse(input)?; + let (input, _) = multispace0.parse(input)?; + let (input, value) = parse_value(input)?; + // Consume rest of line (including any comment) + let (input, _) = opt(pair(take_while(|c| c == ' ' || c == '\t'), comment)).parse(input)?; + let (input, _) = opt(line_ending).parse(input)?; + Ok((input, (key, value))) +} + +/// Parse 25 rows of hex data (60 columns each, 2 hex digits per value). +fn parse_hex_grid(input: &str) -> Result<(&str, Vec), ParseError> { + let mut result = Vec::with_capacity(1500); + let mut input = input; + + for _ in 0..25 { + // Skip whitespace/comments before the row + let (next, _) = ws(input).map_err(|e| ParseError::TextParseError { + message: format!("hex grid whitespace: {:?}", e), + })?; + input = next; + + // Parse 60 hex pairs + for _ in 0..60 { + if input.len() < 2 { + return Err(ParseError::InvalidHex("unexpected end of hex data".to_string())); + } + let hex_str = &input[..2]; + let byte = u8::from_str_radix(hex_str, 16) + .map_err(|_| ParseError::InvalidHex(hex_str.to_string()))?; + result.push(byte); + input = &input[2..]; + } + + // Consume newline if present + if input.starts_with('\n') { + input = &input[1..]; + } else if input.starts_with("\r\n") { + input = &input[2..]; + } + } + + Ok((input, result)) +} + +/// Parse terrain: element grid followed by color grid. +fn parse_terrain(input: &str) -> Result<(&str, Vec), ParseError> { + let (input, elements) = parse_hex_grid(input)?; + let (input, colors) = parse_hex_grid(input)?; + + let tiles: Vec = elements + .into_iter() + .zip(colors) + .map(|(element, color)| Tile { element, color }) + .collect(); + + Ok((input, tiles)) +} + +/// Parse keys string like "bgcr" into bool array. +fn parse_keys(s: &str) -> [bool; 7] { + let key_chars = ['b', 'g', 'c', 'r', 'p', 'y', 'w']; + let mut keys = [false; 7]; + for (i, c) in key_chars.iter().enumerate() { + keys[i] = s.contains(*c); + } + keys +} + +/// Check if the next non-whitespace is a section header. +fn peek_section(input: &str) -> bool { + let trimmed = input.trim_start(); + trimmed.starts_with('[') +} + +/// Check if input is at end or only has whitespace/comments. +fn at_end(input: &str) -> bool { + match ws(input) { + Ok((rest, _)) => rest.is_empty(), + Err(_) => false, + } +} + +/// Parse the [world] section properties. +fn parse_world_section(input: &str) -> Result<(&str, World), ParseError> { + let (input, _) = section_header("world")(input).map_err(|e| ParseError::TextParseError { + message: format!("expected [world] section: {:?}", e), + })?; + + let mut world = World { + health: 100, + ..Default::default() + }; + + let mut input = input; + while !at_end(input) && !peek_section(input) { + let (next, (key, value)) = + parse_key_value(input).map_err(|e| ParseError::TextParseError { + message: format!("world property: {:?}", e), + })?; + input = next; + + match key { + "name" => { + if let Value::String(s) = value { + world.name = s; + } + } + "health" => { + if let Value::Int(n) = value { + world.health = n as i16; + } + } + "ammo" => { + if let Value::Int(n) = value { + world.ammo = n as i16; + } + } + "gems" => { + if let Value::Int(n) = value { + world.gems = n as i16; + } + } + "torches" => { + if let Value::Int(n) = value { + world.torches = n as i16; + } + } + "score" => { + if let Value::Int(n) = value { + world.score = n as i16; + } + } + "keys" => { + if let Value::String(s) = value { + world.keys = parse_keys(&s); + } + } + "starting_board" => { + if let Value::Int(n) = value { + world.starting_board = n as i16; + } + } + "saved_game" => { + if let Value::Bool(b) = value { + world.locked = b; + } + } + "flags" => { + if let Value::StringArray(arr) = value { + for (i, flag) in arr.into_iter().take(10).enumerate() { + world.flags[i] = flag; + } + } + } + "torch_cycles" => { + if let Value::Int(n) = value { + world.torch_cycles = n as i16; + } + } + "energizer_cycles" => { + if let Value::Int(n) = value { + world.energizer_cycles = n as i16; + } + } + "time" => { + if let Value::Int(n) = value { + world.time = n as i16; + } + } + "time_ticks" => { + if let Value::Int(n) = value { + world.time_ticks = n as i16; + } + } + _ => {} // Ignore unknown keys + } + } + + Ok((input, world)) +} + +/// Get element at (x, y) from tiles array. +fn element_at(tiles: &[Tile], x: u8, y: u8) -> Option { + if x == 0 || y == 0 || x > 60 || y > 25 { + return None; + } + let index = ((y as usize - 1) * 60) + (x as usize - 1); + tiles.get(index).map(|t| t.element) +} + +/// Parse a [stat N] section. +fn parse_stat_section<'a>(input: &'a str, tiles: &[Tile]) -> Result<(&'a str, Stat), ParseError> { + let (input, _) = section_header("stat")(input).map_err(|e| ParseError::TextParseError { + message: format!("expected [stat] section: {:?}", e), + })?; + + // First pass: collect all key-value pairs + let mut pairs: Vec<(&str, Value)> = Vec::new(); + let mut input = input; + while !at_end(input) && !peek_section(input) { + let (next, pair) = parse_key_value(input).map_err(|e| ParseError::TextParseError { + message: format!("stat property: {:?}", e), + })?; + pairs.push(pair); + input = next; + } + + // Extract x, y first to determine element type + let mut x: u8 = 0; + let mut y: u8 = 0; + for (key, value) in &pairs { + match *key { + "x" => { + if let Value::Int(n) = value { + x = *n as u8; + } + } + "y" => { + if let Value::Int(n) = value { + y = *n as u8; + } + } + _ => {} + } + } + + let element = element_at(tiles, x, y).and_then(Element::from_u8); + + // Build stat with defaults + let mut stat = Stat { + x, + y, + x_step: 0, + y_step: 0, + cycle: 0, + p1: 0, + p2: 0, + p3: 0, + follower: -1, + leader: -1, + under: Tile { + element: 0, + color: 0, + }, + instruction_pointer: 0, + program: Program::Own(String::new()), + }; + + // Second pass: apply all properties + for (key, value) in pairs { + match key { + "x" | "y" => {} // Already handled + "cycle" => { + if let Value::Int(n) = value { + stat.cycle = n as i16; + } + } + "x_step" => { + if let Value::Int(n) = value { + stat.x_step = n as i16; + } + } + "y_step" => { + if let Value::Int(n) = value { + stat.y_step = n as i16; + } + } + "under" => { + if let Value::Tuple2(e, c) = value { + stat.under = Tile { + element: e, + color: c, + }; + } + } + "follower" => { + if let Value::Int(n) = value { + stat.follower = n as i16; + } + } + "leader" => { + if let Value::Int(n) = value { + stat.leader = n as i16; + } + } + "instruction_pointer" => { + if let Value::Int(n) = value { + stat.instruction_pointer = n as i16; + } + } + "p1" => { + if let Value::Int(n) = value { + stat.p1 = n as u8; + } + } + "p2" => { + if let Value::Int(n) = value { + stat.p2 = n as u8; + } + } + "p3" => { + if let Value::Int(n) = value { + stat.p3 = n as u8; + } + } + "code" => { + if let Value::TripleQuotedString(s) = value { + stat.program = Program::Own(s); + } + } + "bind" => { + if let Value::Int(n) = value { + stat.program = Program::Bound(n as u16); + } + } + other => { + // Check if it's a parameter alias + if let Some(param_num) = resolve_alias(other, element) { + if let Value::Int(n) = value { + match param_num { + 1 => stat.p1 = n as u8, + 2 => stat.p2 = n as u8, + 3 => stat.p3 = n as u8, + _ => {} + } + } + } + // Ignore unknown keys + } + } + } + + Ok((input, stat)) +} + +/// Parse a [board N] section. +fn parse_board_section(input: &str) -> Result<(&str, Board), ParseError> { + let (input, _) = section_header("board")(input).map_err(|e| ParseError::TextParseError { + message: format!("expected [board] section: {:?}", e), + })?; + + // Parse title first (required before terrain) + let (input, (_, title_value)) = + parse_key_value(input).map_err(|e| ParseError::TextParseError { + message: format!("expected board title: {:?}", e), + })?; + + let title = match title_value { + Value::String(s) => s, + _ => String::new(), + }; + + // Parse terrain + let (input, tiles) = parse_terrain(input)?; + + // Parse board properties + let mut board = Board { + name: title, + tiles, + max_shots: 0, + is_dark: false, + exit_north: 0, + exit_south: 0, + exit_west: 0, + exit_east: 0, + restart_on_zap: false, + message: String::new(), + enter_x: 0, + enter_y: 0, + time_limit: 0, + stats: Vec::new(), + }; + + let mut input = input; + + // Parse remaining properties until we hit a stat section or end + while !at_end(input) && !peek_section(input) { + let (next, (key, value)) = + parse_key_value(input).map_err(|e| ParseError::TextParseError { + message: format!("board property: {:?}", e), + })?; + input = next; + + match key { + "shots" => { + if let Value::Int(n) = value { + board.max_shots = n as u8; + } + } + "dark" => { + if let Value::Bool(b) = value { + board.is_dark = b; + } + } + "exit_n" => { + if let Value::Int(n) = value { + board.exit_north = n as u8; + } + } + "exit_s" => { + if let Value::Int(n) = value { + board.exit_south = n as u8; + } + } + "exit_e" => { + if let Value::Int(n) = value { + board.exit_east = n as u8; + } + } + "exit_w" => { + if let Value::Int(n) = value { + board.exit_west = n as u8; + } + } + "reenter" => { + if let Value::Bool(b) = value { + board.restart_on_zap = b; + } + } + "time_limit" => { + if let Value::Int(n) = value { + board.time_limit = n as i16; + } + } + "enter_x" => { + if let Value::Int(n) = value { + board.enter_x = n as u8; + } + } + "enter_y" => { + if let Value::Int(n) = value { + board.enter_y = n as u8; + } + } + "message" => { + if let Value::String(s) = value { + board.message = s; + } + } + _ => {} // Ignore unknown keys + } + } + + // Parse stats + while !at_end(input) { + // Check if next section is a stat + let trimmed = input.trim_start(); + if !trimmed.starts_with("[stat") { + break; + } + let (next, stat) = parse_stat_section(input, &board.tiles)?; + board.stats.push(stat); + input = next; + } + + Ok((input, board)) +} + +/// Convert text to a World. +pub fn text_to_world(text: &str) -> Result { + let (input, mut world) = parse_world_section(text)?; + + let mut input = input; + while !at_end(input) { + let (next, board) = parse_board_section(input)?; + world.boards.push(board); + input = next; + } + + Ok(world) +} + +/// Convert text to a Board. +pub fn text_to_board(text: &str) -> Result { + let (_, board) = parse_board_section(text)?; + Ok(board) +}