7f0f6421d3
- Combine `x` and `y` into `at` - Combine `x_step` and `y_step` into `step`
1061 lines
33 KiB
Rust
1061 lines
33 KiB
Rust
use std::collections::HashMap;
|
|
use std::fmt::Write;
|
|
|
|
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_id_from_name, element_name, resolve_alias};
|
|
use super::error::ParseError;
|
|
use super::parse::{Board, Program, Stat, Tile, World};
|
|
|
|
/// Write key = value if value != default.
|
|
macro_rules! kv {
|
|
($out:expr, $key:expr, $val:expr, $default:expr) => {
|
|
if $val != $default {
|
|
writeln!($out, "{} = {}", $key, $val).unwrap();
|
|
}
|
|
};
|
|
}
|
|
|
|
/// Write key = true if value is true.
|
|
macro_rules! kv_bool {
|
|
($out:expr, $key:expr, $val:expr) => {
|
|
if $val {
|
|
writeln!($out, "{} = true", $key).unwrap();
|
|
}
|
|
};
|
|
}
|
|
|
|
/// Write key = "value" if value != default.
|
|
macro_rules! kv_str {
|
|
($out:expr, $key:expr, $val:expr, $default:expr) => {
|
|
if $val != $default {
|
|
writeln!($out, "{} = {:?}", $key, $val).unwrap();
|
|
}
|
|
};
|
|
}
|
|
|
|
/// Convert a World to its text representation.
|
|
pub fn world_to_text(world: &World) -> String {
|
|
let mut output = String::new();
|
|
write_world_header(&mut output, world);
|
|
|
|
for (i, board) in world.boards.iter().enumerate() {
|
|
output.push_str("\n\n");
|
|
write_board(&mut output, Some(i), board);
|
|
}
|
|
|
|
output
|
|
}
|
|
|
|
/// Convert a standalone Board to its text representation.
|
|
pub fn board_to_text(board: &Board) -> String {
|
|
let mut output = String::new();
|
|
write_board(&mut output, None, board);
|
|
output
|
|
}
|
|
|
|
fn write_world_header(output: &mut String, world: &World) {
|
|
output.push_str("[world]\n");
|
|
writeln!(output, "name = {:?}", world.name).unwrap();
|
|
|
|
kv!(output, "health", world.health, 100);
|
|
kv!(output, "ammo", world.ammo, 0);
|
|
kv!(output, "gems", world.gems, 0);
|
|
kv!(output, "torches", world.torches, 0);
|
|
kv!(output, "score", world.score, 0);
|
|
|
|
let key_str = keys_to_string(&world.keys);
|
|
if !key_str.is_empty() {
|
|
writeln!(output, "keys = {:?}", key_str).unwrap();
|
|
}
|
|
|
|
kv!(output, "starting_board", world.starting_board, 0);
|
|
kv_bool!(output, "saved_game", world.locked);
|
|
|
|
// Print flags until we've printed all the non-empty ones.
|
|
// If there are empty ones in between, they will be printed, e.g. ["foo", "", "bar"],
|
|
// but the empty ones at the end will be omitted.
|
|
let last_non_empty = world.flags.iter().rposition(|f| !f.is_empty());
|
|
if let Some(last_idx) = last_non_empty {
|
|
let flags = &world.flags[..=last_idx];
|
|
write!(output, "flags = [").unwrap();
|
|
for (i, flag) in flags.iter().enumerate() {
|
|
if i > 0 {
|
|
output.push_str(", ");
|
|
}
|
|
write!(output, "{:?}", flag).unwrap();
|
|
}
|
|
output.push_str("]\n");
|
|
}
|
|
|
|
kv!(output, "torch_cycles", world.torch_cycles, 0);
|
|
kv!(output, "energizer_cycles", world.energizer_cycles, 0);
|
|
kv!(output, "time", world.time, 0);
|
|
kv!(output, "time_ticks", world.time_ticks, 0);
|
|
}
|
|
|
|
fn keys_to_string(keys: &[bool; 7]) -> String {
|
|
let key_chars = ['b', 'g', 'c', 'r', 'p', 'y', 'w'];
|
|
keys.iter()
|
|
.zip(key_chars.iter())
|
|
.filter(|(has_key, _)| **has_key)
|
|
.map(|(_, c)| *c)
|
|
.collect()
|
|
}
|
|
|
|
fn write_board(output: &mut String, index: Option<usize>, board: &Board) {
|
|
// Header cluster (always present)
|
|
match index {
|
|
Some(i) => writeln!(output, "[board {}]", i).unwrap(),
|
|
None => output.push_str("[board]\n"),
|
|
}
|
|
writeln!(output, "title = {:?}", board.name).unwrap();
|
|
|
|
// Terrain cluster (always present)
|
|
output.push('\n');
|
|
write_terrain(output, &board.tiles);
|
|
|
|
// Board properties cluster (may be empty)
|
|
let mut props = String::new();
|
|
write_board_properties(&mut props, board);
|
|
|
|
// Stats cluster (may be empty)
|
|
let mut stats = String::new();
|
|
for (i, stat) in board.stats.iter().enumerate() {
|
|
if i > 0 {
|
|
stats.push('\n'); // blank between stats
|
|
}
|
|
let element = get_element_at(board, stat.x, stat.y);
|
|
write_stat(&mut stats, i, stat, element);
|
|
}
|
|
|
|
// Join non-empty clusters with single blank lines
|
|
for cluster in [props, stats] {
|
|
if !cluster.is_empty() {
|
|
output.push('\n');
|
|
output.push_str(&cluster);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn write_board_properties(output: &mut String, board: &Board) {
|
|
kv!(output, "shots", board.max_shots, 255);
|
|
kv_bool!(output, "dark", board.is_dark);
|
|
kv!(output, "exit_n", board.exit_north, 0);
|
|
kv!(output, "exit_s", board.exit_south, 0);
|
|
kv!(output, "exit_e", board.exit_east, 0);
|
|
kv!(output, "exit_w", board.exit_west, 0);
|
|
kv_bool!(output, "reenter", board.restart_on_zap);
|
|
kv!(output, "time_limit", board.time_limit, 0);
|
|
kv!(output, "enter_x", board.enter_x, 1);
|
|
kv!(output, "enter_y", board.enter_y, 1);
|
|
kv_str!(output, "message", &board.message, "");
|
|
}
|
|
|
|
fn write_terrain(output: &mut String, tiles: &[Tile]) {
|
|
// Elements: 60x25 grid, element bytes as 2-digit hex
|
|
for row in 0..25 {
|
|
for col in 0..60 {
|
|
let tile = &tiles[row * 60 + col];
|
|
write!(output, "{:02x}", tile.element).unwrap();
|
|
}
|
|
output.push('\n');
|
|
}
|
|
|
|
output.push('\n');
|
|
|
|
// Colors: 60x25 grid, color bytes as 2-digit hex
|
|
for row in 0..25 {
|
|
for col in 0..60 {
|
|
let tile = &tiles[row * 60 + col];
|
|
write!(output, "{:02x}", tile.color).unwrap();
|
|
}
|
|
output.push('\n');
|
|
}
|
|
}
|
|
|
|
fn get_element_at(board: &Board, x: u8, y: u8) -> Option<u8> {
|
|
if x == 0 || y == 0 || x > 60 || y > 25 {
|
|
return None;
|
|
}
|
|
let index = ((y as usize - 1) * 60) + (x as usize - 1);
|
|
board.tiles.get(index).map(|t| t.element)
|
|
}
|
|
|
|
fn write_stat(output: &mut String, index: usize, stat: &Stat, element: Option<u8>) {
|
|
// Stat header with element type comment
|
|
let element_comment = match element {
|
|
Some(id) => element_name(id),
|
|
None => "off-board".to_string(),
|
|
};
|
|
writeln!(output, "[stat {}] # {}", index, element_comment).unwrap();
|
|
|
|
writeln!(output, "at = ({}, {})", stat.x, stat.y).unwrap();
|
|
|
|
kv!(output, "cycle", stat.cycle, 0);
|
|
if stat.x_step != 0 || stat.y_step != 0 {
|
|
writeln!(output, "step = ({}, {})", stat.x_step, stat.y_step).unwrap();
|
|
}
|
|
|
|
if stat.under.element != Element::Empty as u8 || stat.under.color != 0x0f {
|
|
let elem_name = match Element::from_u8(stat.under.element) {
|
|
Some(e) => e.name().to_string(),
|
|
None => format!("unknown_{}", stat.under.element),
|
|
};
|
|
writeln!(
|
|
output,
|
|
"under = ({}, 0x{:02x})",
|
|
elem_name, stat.under.color
|
|
)
|
|
.unwrap();
|
|
}
|
|
|
|
kv!(output, "follower", stat.follower, -1);
|
|
kv!(output, "leader", stat.leader, -1);
|
|
kv!(output, "instruction_pointer", stat.instruction_pointer, 0);
|
|
|
|
// Parameters with element-specific aliases
|
|
let elem = element.and_then(Element::from_u8);
|
|
write_param(output, stat.p1, "p1", elem.and_then(|e| e.p1_alias()));
|
|
write_param(output, stat.p2, "p2", elem.and_then(|e| e.p2_alias()));
|
|
write_param(output, stat.p3, "p3", elem.and_then(|e| e.p3_alias()));
|
|
|
|
// Program/code
|
|
match &stat.program {
|
|
Program::Own(code) if !code.is_empty() => {
|
|
output.push_str("code = \"\"\"\n");
|
|
output.push_str(code);
|
|
if !code.ends_with('\n') {
|
|
output.push('\n');
|
|
}
|
|
output.push_str("\"\"\"\n");
|
|
}
|
|
Program::Bound(idx) => {
|
|
writeln!(output, "bind = {}", idx).unwrap();
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
fn write_param(output: &mut String, value: u8, generic_name: &str, alias: Option<&str>) {
|
|
if value == 0 {
|
|
return;
|
|
}
|
|
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<String>),
|
|
Tuple2(u8, u8),
|
|
SignedTuple2(i16, i16),
|
|
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<usize>> {
|
|
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 hex number like 0x0f or 0x1F.
|
|
fn hex_u8(input: &str) -> IResult<&str, u8> {
|
|
let (input, _) = tag("0x").parse(input)?;
|
|
let (input, hex_str) = take_while1(|c: char| c.is_ascii_hexdigit()).parse(input)?;
|
|
match u8::from_str_radix(hex_str, 16) {
|
|
Ok(n) => Ok((input, n)),
|
|
Err(_) => Err(nom::Err::Error(nom::error::Error::new(
|
|
input,
|
|
nom::error::ErrorKind::HexDigit,
|
|
))),
|
|
}
|
|
}
|
|
|
|
/// Parse a tuple like (element_name, 0xNN) or legacy (element_id, color).
|
|
/// Supports both new format: (empty, 0x0f) and legacy: (0, 15).
|
|
fn tuple2(input: &str) -> IResult<&str, (u8, u8)> {
|
|
let (input, _) = char('(').parse(input)?;
|
|
let (input, _) = multispace0.parse(input)?;
|
|
|
|
// First value: element name (identifier) or decimal number
|
|
let (input, element_id) = alt((
|
|
// Element name (identifier) -> convert to ID
|
|
map(identifier, |name| element_id_from_name(name).unwrap_or(0)),
|
|
// Legacy: decimal element ID
|
|
map_res(digit1, |s: &str| s.parse::<u8>()),
|
|
))
|
|
.parse(input)?;
|
|
|
|
let (input, _) = multispace0.parse(input)?;
|
|
let (input, _) = char(',').parse(input)?;
|
|
let (input, _) = multispace0.parse(input)?;
|
|
|
|
// Second value: hex color (0xNN) or decimal color
|
|
let (input, color) = alt((hex_u8, map_res(digit1, |s: &str| s.parse::<u8>()))).parse(input)?;
|
|
|
|
let (input, _) = multispace0.parse(input)?;
|
|
let (input, _) = char(')').parse(input)?;
|
|
Ok((input, (element_id, color)))
|
|
}
|
|
|
|
/// Parse a tuple of signed integers like (0, -1).
|
|
fn signed_tuple2(input: &str) -> IResult<&str, (i16, i16)> {
|
|
let (input, _) = char('(').parse(input)?;
|
|
let (input, _) = multispace0.parse(input)?;
|
|
let (input, a) = map_res(recognize(pair(opt(char('-')), digit1)), |s: &str| {
|
|
s.parse::<i16>()
|
|
})
|
|
.parse(input)?;
|
|
let (input, _) = multispace0.parse(input)?;
|
|
let (input, _) = char(',').parse(input)?;
|
|
let (input, _) = multispace0.parse(input)?;
|
|
let (input, b) = map_res(recognize(pair(opt(char('-')), digit1)), |s: &str| {
|
|
s.parse::<i16>()
|
|
})
|
|
.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<String>> {
|
|
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(signed_tuple2, |(a, b)| Value::SignedTuple2(a, b)),
|
|
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<u8>), 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<Tile>), ParseError> {
|
|
let (input, elements) = parse_hex_grid(input)?;
|
|
let (input, colors) = parse_hex_grid(input)?;
|
|
|
|
let tiles: Vec<Tile> = 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/non-comment is a section header.
|
|
fn peek_section(input: &str) -> bool {
|
|
match ws(input) {
|
|
Ok((rest, _)) => rest.starts_with('['),
|
|
Err(_) => false,
|
|
}
|
|
}
|
|
|
|
/// 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<u8> {
|
|
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.
|
|
/// Returns (remaining input, stat, optional ephemeral ID).
|
|
fn parse_stat_section<'a>(
|
|
input: &'a str,
|
|
tiles: &[Tile],
|
|
) -> Result<(&'a str, Stat, Option<usize>), ParseError> {
|
|
let (input, ephemeral_id) =
|
|
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 {
|
|
"at" => {
|
|
if let Value::SignedTuple2(nx, ny) = value {
|
|
x = *nx as u8;
|
|
y = *ny 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: Element::Empty as u8,
|
|
color: 0x0f,
|
|
},
|
|
instruction_pointer: 0,
|
|
program: Program::Own(String::new()),
|
|
};
|
|
|
|
// Second pass: apply all properties
|
|
for (key, value) in pairs {
|
|
match key {
|
|
"at" => {} // Already handled
|
|
"cycle" => {
|
|
if let Value::Int(n) = value {
|
|
stat.cycle = n as i16;
|
|
}
|
|
}
|
|
"step" => {
|
|
if let Value::SignedTuple2(xs, ys) = value {
|
|
stat.x_step = xs;
|
|
stat.y_step = ys;
|
|
}
|
|
}
|
|
"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, ephemeral_id))
|
|
}
|
|
|
|
/// Parse a [board N] section.
|
|
/// Returns (remaining input, board, optional ephemeral ID).
|
|
fn parse_board_section(input: &str) -> Result<(&str, Board, Option<usize>), ParseError> {
|
|
let (input, ephemeral_id) =
|
|
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: 255,
|
|
is_dark: false,
|
|
exit_north: 0,
|
|
exit_south: 0,
|
|
exit_west: 0,
|
|
exit_east: 0,
|
|
restart_on_zap: false,
|
|
message: String::new(),
|
|
enter_x: 1,
|
|
enter_y: 1,
|
|
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 with ephemeral IDs
|
|
let mut stats_with_ids: Vec<(Stat, Option<usize>)> = Vec::new();
|
|
while !at_end(input) {
|
|
// Check if next section is a stat (skip whitespace and comments first)
|
|
let after_ws = ws(input).map(|(rest, _)| rest).unwrap_or(input);
|
|
if !after_ws.starts_with("[stat") {
|
|
break;
|
|
}
|
|
let (next, stat, stat_ephemeral_id) = parse_stat_section(input, &board.tiles)?;
|
|
stats_with_ids.push((stat, stat_ephemeral_id));
|
|
input = next;
|
|
}
|
|
|
|
// Build stat ID -> index mapping
|
|
let stat_map: HashMap<usize, usize> = stats_with_ids
|
|
.iter()
|
|
.enumerate()
|
|
.filter_map(|(idx, (_, id))| id.map(|id| (id, idx)))
|
|
.collect();
|
|
|
|
// Move stats into board
|
|
for (stat, _) in stats_with_ids {
|
|
board.stats.push(stat);
|
|
}
|
|
|
|
// Remap stat references
|
|
remap_stat_refs(&mut board, &stat_map);
|
|
|
|
Ok((input, board, ephemeral_id))
|
|
}
|
|
|
|
/// Remap a board reference using the ephemeral ID -> index mapping.
|
|
/// Returns the value unchanged if no mapping exists.
|
|
fn remap_board_ref(value: u8, map: &HashMap<usize, usize>) -> u8 {
|
|
map.get(&(value as usize))
|
|
.map(|&idx| idx as u8)
|
|
.unwrap_or(value)
|
|
}
|
|
|
|
/// Remap board references in a board's exits and passage stats.
|
|
fn remap_board_refs(board: &mut Board, map: &HashMap<usize, usize>) {
|
|
board.exit_north = remap_board_ref(board.exit_north, map);
|
|
board.exit_south = remap_board_ref(board.exit_south, map);
|
|
board.exit_east = remap_board_ref(board.exit_east, map);
|
|
board.exit_west = remap_board_ref(board.exit_west, map);
|
|
|
|
// Remap passage destinations (p3 for Passage elements)
|
|
for stat in &mut board.stats {
|
|
let element = element_at(&board.tiles, stat.x, stat.y);
|
|
if element == Some(Element::Passage as u8) {
|
|
stat.p3 = remap_board_ref(stat.p3, map);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Remap stat references (follower, leader, bind) using ephemeral ID -> index mapping.
|
|
fn remap_stat_refs(board: &mut Board, map: &HashMap<usize, usize>) {
|
|
for stat in &mut board.stats {
|
|
// Remap follower (-1 means none)
|
|
if stat.follower >= 0 {
|
|
if let Some(&new_idx) = map.get(&(stat.follower as usize)) {
|
|
stat.follower = new_idx as i16;
|
|
}
|
|
}
|
|
// Remap leader (-1 means none)
|
|
if stat.leader >= 0 {
|
|
if let Some(&new_idx) = map.get(&(stat.leader as usize)) {
|
|
stat.leader = new_idx as i16;
|
|
}
|
|
}
|
|
// Remap bind references
|
|
if let Program::Bound(idx) = &stat.program {
|
|
if let Some(&new_idx) = map.get(&(*idx as usize)) {
|
|
stat.program = Program::Bound(new_idx as u16);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Convert text to a World.
|
|
pub fn text_to_world(text: &str) -> Result<World, ParseError> {
|
|
let (input, mut world) = parse_world_section(text)?;
|
|
|
|
// Collect boards with their ephemeral IDs
|
|
let mut boards_with_ids: Vec<(Board, Option<usize>)> = Vec::new();
|
|
let mut input = input;
|
|
while !at_end(input) {
|
|
let (next, board, ephemeral_id) = parse_board_section(input)?;
|
|
boards_with_ids.push((board, ephemeral_id));
|
|
input = next;
|
|
}
|
|
|
|
// Build board ID -> index mapping
|
|
let board_map: HashMap<usize, usize> = boards_with_ids
|
|
.iter()
|
|
.enumerate()
|
|
.filter_map(|(idx, (_, id))| id.map(|id| (id, idx)))
|
|
.collect();
|
|
|
|
// Move boards into world
|
|
for (board, _) in boards_with_ids {
|
|
world.boards.push(board);
|
|
}
|
|
|
|
// Remap world.starting_board
|
|
if let Some(&new_idx) = board_map.get(&(world.starting_board as usize)) {
|
|
world.starting_board = new_idx as i16;
|
|
}
|
|
|
|
// Remap board references in each board
|
|
for board in &mut world.boards {
|
|
remap_board_refs(board, &board_map);
|
|
}
|
|
|
|
Ok(world)
|
|
}
|
|
|
|
/// Convert text to a Board.
|
|
pub fn text_to_board(text: &str) -> Result<Board, ParseError> {
|
|
let (_, board, _) = parse_board_section(text)?;
|
|
Ok(board)
|
|
}
|