quest.rs

9.6 kB · rust · 308 lines

1use crate::config::Config;2use crate::frames::secondaries;3use crate::music::{score, Score};4use crate::sequence::{rulebook, Rulebook};5use crate::variations::{board, mask, Board};6use crate::{Path, Way};7use mrlycore::errors::{value_error, Result};8use mrlycore::paint::Ink;9use mrlycore::state::{choice, randint, seed};10use mrlymath::life::{crop, moore, Boundary, Config as LifeConfig, Counts, Fate, Story};11use mrlymath::two::Cell2d;1213const MIN_PIVOT: usize = 8;1415/// Returns the multiples of four a run of the count may pivot at, shortest first.16pub fn pivot_options(count: usize) -> Vec<usize> {17    let lo = count.min(MIN_PIVOT).div_ceil(4) * 4;18    let hi = (count / 4) * 4;19    if hi < lo {20        return Vec::new();21    }22    (lo..=hi).step_by(4).collect()23}2425/// One chapter's editorial record: identity, way, mask, rule and score.26#[derive(Clone, Debug)]27pub struct Segment {28    /// The random hex identifier.29    pub key: String,30    /// The rulebook family.31    pub way: Way,32    /// The mask path.33    pub path: Path,34    /// The accent ink woven through the palettes.35    pub accent: Ink,36    /// The popped neighborhood mask.37    pub mask: Cell2d,38    /// The drawn rule, or None under Conway.39    pub rulebook: Option<Rulebook>,40    /// The musical styling.41    pub score: Score,42}4344/// One finished quest: the story, its segments and the shared stage.45#[derive(Clone, Debug)]46pub struct Quest {47    /// The random hex identifier.48    pub key: String,49    /// The seed the attempt ran under.50    pub seed: u64,51    /// The edge policy every chapter runs under.52    pub boundary: Boundary,53    /// The primary ink.54    pub primary: Ink,55    /// The canvas side in cells before cropping.56    pub canvas: usize,57    /// The chaptered engine record.58    pub story: Story,59    /// The editorial record, one per chapter.60    pub segments: Vec<Segment>,61    /// Every frame, cropped to the lived-in square.62    pub grids: Vec<Cell2d>,63}6465impl Quest {66    /// Returns the quest's canonical mrly name.67    pub fn name(&self) -> String {68        format!("mrly_quest_{}", self.key)69    }70}7172fn hex_key(length: usize) -> String {73    const DIGITS: &[u8; 16] = b"0123456789abcdef";74    (0..length)75        .map(|_| DIGITS[randint(0, 15) as usize] as char)76        .collect()77}7879fn setup_segment(prev: Option<(&Cell2d, Way)>, config: &Config) -> Result<(Board, Segment)> {80    let key = hex_key(8);81    let accent = choice(&secondaries());82    let (way, stage) = match prev {83        None => {84            let way = choice(&[Way::Conway, Way::Mrly]);85            (way, board(config)?)86        }87        Some((grid, prev_way)) => (prev_way.flip(), Board::of(grid.clone())),88    };89    let (path, mask_cell, rule) = match way {90        Way::Conway => (Path::Simple, moore(), None),91        Way::Mrly => {92            let (path, cell) = mask(&stage, config)?;93            let rule = rulebook(path == Path::Simple);94            (path, cell, Some(rule))95        }96    };97    let segment = Segment {98        key,99        way,100        path,101        accent,102        mask: mask_cell,103        rulebook: rule,104        score: score(way),105    };106    Ok((stage, segment))107}108109fn chapter_config(segment: &Segment, boundary: Boundary, stage: &Board, cap: usize) -> LifeConfig {110    let (birth, survive) = match &segment.rulebook {111        Some(rule) => (rule.birth(), rule.survive()),112        None => (Counts::from(vec![3]), Counts::from(vec![2, 3])),113    };114    LifeConfig {115        boundary,116        max_generations: cap,117        grid_size: stage.grid,118        padding: stage.padding(),119        ..LifeConfig::new(segment.mask.clone(), birth, survive)120    }121}122123fn attempt(config: &Config) -> Result<Option<Quest>> {124    let s = randint(0, i64::MAX) as u64;125    seed(s);126    let key = hex_key(8);127    let boundary = choice(&[Boundary::Constant, Boundary::Wrap]);128    let primary = Ink::Black;129    let mut story = Story::new();130    let mut segments: Vec<Segment> = Vec::new();131    let mut canvas = 0;132    let mut prev: Option<Cell2d> = None;133    for index in 0..config.max_segments {134        let chained = match (&prev, segments.last()) {135            (Some(grid), Some(last)) => Some((grid, last.way)),136            _ => None,137        };138        let (stage, segment) = setup_segment(chained, config)?;139        if index == 0 {140            canvas = stage.canvas_unit();141        }142        let chapter = chapter_config(&segment, boundary, &stage, config.max_generations);143        story.add(&stage.cell, &chapter)?;144        segments.push(segment);145        if story.fate()? == Fate::Alive {146            let grids = crop(&story.grids());147            return Ok(Some(Quest {148                key,149                seed: s,150                boundary,151                primary,152                canvas,153                story,154                segments,155                grids,156            }));157        }158        let count = story.chapters.last().map_or(0, |c| c.life.count);159        let options = pivot_options(count);160        let length = if options.is_empty() {161            count162        } else {163            choice(&options)164        };165        prev = Some(story.pivot(length)?);166    }167    Ok(None)168}169170/// Runs the quest: seeded attempts retried until a chapter settles alive.171pub fn quest(config: &Config) -> Result<Quest> {172    for _ in 0..config.attempts.max(1) {173        if let Some(found) = attempt(config)? {174            return Ok(found);175        }176    }177    value_error("no attempt settled alive within the budget.")178}179180#[cfg(test)]181mod tests {182    use super::*;183    use mrlycore::state::guard;184    use mrlycore::Json;185186    pub fn tiny() -> Config {187        Config {188            max_generations: 16,189            max_segments: 4,190            max_canvas: 15,191            min_tile: 3,192            max_tile: 5,193            min_mask: 3,194            max_mask: 5,195            attempts: 64,196        }197    }198199    const PINNED: u64 = 1;200201    #[test]202    fn the_quest_settles_alive_on_the_pinned_seed() {203        let _g = guard();204        mrlycore::state::seed(PINNED);205        let found = quest(&tiny()).unwrap();206        assert_eq!(found.story.fate().unwrap(), Fate::Alive);207        assert!(!found.segments.is_empty());208        assert!(found.segments.len() <= 4);209        assert_eq!(found.segments.len(), found.story.chapters.len());210        assert_eq!(found.grids.len(), found.story.count());211        assert!(found.canvas <= 15);212        assert!(found.grids[0].width() <= found.canvas);213    }214215    #[test]216    fn the_name_composes_the_key_canonically() {217        let _g = guard();218        mrlycore::state::seed(PINNED);219        let found = quest(&tiny()).unwrap();220        let name = found.name();221        assert_eq!(name, format!("mrly_quest_{}", found.key));222        assert!(name223            .chars()224            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_'));225    }226227    #[test]228    fn ways_flip_between_chained_segments() {229        let _g = guard();230        mrlycore::state::seed(PINNED);231        let found = quest(&tiny()).unwrap();232        for pair in found.segments.windows(2) {233            assert_eq!(pair[1].way, pair[0].way.flip());234        }235        for segment in &found.segments {236            match segment.way {237                Way::Conway => {238                    assert_eq!(segment.path, Path::Simple);239                    assert!(segment.rulebook.is_none());240                    assert_eq!(segment.mask.types(), moore().types());241                }242                Way::Mrly => assert!(segment.rulebook.is_some()),243            }244        }245    }246247    #[test]248    fn pivot_options_step_by_four() {249        assert_eq!(pivot_options(20), vec![8, 12, 16, 20]);250        assert_eq!(pivot_options(12), vec![8, 12]);251        assert_eq!(pivot_options(9), vec![8]);252        assert_eq!(pivot_options(8), vec![8]);253        assert_eq!(pivot_options(4), vec![4]);254        assert_eq!(pivot_options(7), Vec::<usize>::new());255        assert_eq!(pivot_options(3), Vec::<usize>::new());256        assert_eq!(pivot_options(0), vec![0]);257    }258259    #[test]260    fn every_pivot_lands_on_a_multiple_of_four() {261        let _g = guard();262        mrlycore::state::seed(PINNED);263        let found = quest(&tiny()).unwrap();264        let lengths = found.story.chapter_lengths();265        for &length in &lengths[..lengths.len() - 1] {266            assert!(length % 4 == 0 || pivot_options(length).is_empty());267        }268    }269270    #[test]271    fn a_wide_mask_quest_replays_from_its_names() {272        let _g = guard();273        let mut wide = false;274        for s in 1..=16u64 {275            mrlycore::state::seed(s);276            let found = quest(&tiny()).unwrap();277            let back = Story::from_json(&found.story.to_json().unwrap()).unwrap();278            assert_eq!(back.chapter_lengths(), found.story.chapter_lengths());279            for (a, b) in found.story.grids().iter().zip(back.grids()) {280                assert_eq!(a.types(), b.types());281            }282            for (a, b) in found.story.chapters.iter().zip(&back.chapters) {283                let counts = a.config.counts().unwrap();284                assert_eq!(counts, b.config.counts().unwrap());285                wide |= counts.0.iter().chain(&counts.1).any(|&n| n > 9);286            }287        }288        assert!(wide, "no chapter counted past nine");289    }290291    #[test]292    fn the_quest_replays_from_one_outer_seed() {293        let _g = guard();294        mrlycore::state::seed(PINNED);295        let a = quest(&tiny()).unwrap();296        mrlycore::state::seed(PINNED);297        let b = quest(&tiny()).unwrap();298        assert_eq!(a.key, b.key);299        assert_eq!(a.seed, b.seed);300        assert_eq!(301            Json::to_string(&a.story.to_json().unwrap()),302            Json::to_string(&b.story.to_json().unwrap())303        );304        for (x, y) in a.grids.iter().zip(&b.grids) {305            assert_eq!(x.types(), y.types());306        }307    }308}