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}