emit.rs

9.8 kB · rust · 280 lines

1use crate::config::{Config, FPS, FREEZE_US, HEATMAP_FPS, INTER_FREEZE_US, SIZE};2use crate::frames::{frame_palette, heat_colorizer, span_peak};3use crate::music::{soundtrack, Score};4use crate::quest::{quest, Quest};5use crate::Path;6use mrlycore::errors::Result;7use mrlycore::io::{make, write};8use mrlycore::{json, state, Json};9use mrlymath::life::{frames_with, heatmap_range};10use mrlymusic::{audio, wav};11use std::path::Path as Dir;1213/// The default seed an emit runs under.14pub const SEED: u64 = 7;1516/// Generates a quest from the seed under the default caps and writes it into the directory.17pub fn emit(dir: &Dir, seed: u64) -> Result<Json> {18    emit_with(dir, seed, &Config::default())19}2021/// Generates a quest from the seed and writes frames, heatmap, audio and record into the directory.22pub fn emit_with(dir: &Dir, seed: u64, config: &Config) -> Result<Json> {23    state::seed(seed);24    let found = quest(config)?;25    write_frames(dir, &found)?;26    write_heatmap(dir, &found)?;27    let scores: Vec<Score> = found.segments.iter().map(|s| s.score.clone()).collect();28    let samples = soundtrack(&scores, &found.story.chapter_lengths());29    write(30        &dir.join("audio.wav"),31        &wav(&audio::pcm(&samples), audio::RATE),32    )?;33    let record = record(&found, seed)?;34    let text = serde_json::to_string_pretty(&record)? + "\n";35    write(&dir.join("quest.json"), text.as_bytes())?;36    Ok(record)37}3839fn write_frames(dir: &Dir, found: &Quest) -> Result<()> {40    let home = dir.join("frames");41    make(&home)?;42    let lengths = found.story.chapter_lengths();43    let mut cursor = 0;44    let mut number = 0;45    for (i, segment) in found.segments.iter().enumerate() {46        let span = &found.grids[cursor..cursor + lengths[i]];47        let wanted = segment.mask.types().sum() as usize + 1;48        let palette = frame_palette(segment.accent, segment.path == Path::Simple, wanted);49        let pngs = frames_with(50            span,51            &segment.mask,52            found.boundary,53            1,54            found.primary.color(),55            &palette,56        )?;57        for png in &pngs {58            number += 1;59            write(&home.join(format!("{number:04}.png")), png)?;60        }61        cursor += lengths[i];62    }63    Ok(())64}6566fn write_heatmap(dir: &Dir, found: &Quest) -> Result<()> {67    let home = dir.join("heatmap");68    make(&home)?;69    let lengths = found.story.chapter_lengths();70    let mut cursor = 0;71    let mut number = 0;72    for (i, segment) in found.segments.iter().enumerate() {73        let end = cursor + lengths[i];74        let peak = span_peak(&found.grids[cursor..end]);75        let colorizer = heat_colorizer(found.primary, segment.accent, peak)?;76        let pngs = heatmap_range(&found.grids, cursor, end, &colorizer, 1)?;77        for png in &pngs {78            number += 1;79            write(&home.join(format!("{number:04}.png")), png)?;80        }81        cursor = end;82    }83    Ok(())84}8586fn timeline(lengths: &[usize], frame_us: u64) -> Vec<Json> {87    let total: usize = lengths.iter().sum();88    if total == 0 {89        return Vec::new();90    }91    let mut out = Vec::with_capacity(total + lengths.len() + 1);92    out.push(json!({ "frame": 1, "us": FREEZE_US }));93    let mut cursor = 0;94    for (i, &length) in lengths.iter().enumerate() {95        for frame in cursor..cursor + length {96            out.push(json!({ "frame": frame + 1, "us": frame_us }));97        }98        cursor += length;99        if i < lengths.len() - 1 {100            out.push(json!({ "frame": cursor, "us": INTER_FREEZE_US }));101        }102    }103    out.push(json!({ "frame": total, "us": FREEZE_US }));104    out105}106107fn record(found: &Quest, seed: u64) -> Result<Json> {108    let lengths = found.story.chapter_lengths();109    Ok(json!({110        "v": 1,111        "key": found.key.clone(),112        "name": found.name(),113        "seed": seed.to_string(),114        "story": found.story.to_json()?,115        "manifest": {116            "fps": FPS,117            "heatmap_fps": HEATMAP_FPS,118            "canvas": found.grids.first().map_or(0, |g| g.width()),119            "size": SIZE,120            "audio": "audio.wav",121            "segments": lengths.clone(),122            "freeze_us": FREEZE_US,123            "inter_freeze_us": INTER_FREEZE_US,124            "frames": timeline(&lengths, 1_000_000 / FPS as u64),125            "heatmap": timeline(&lengths, 1_000_000 / HEATMAP_FPS as u64),126        },127    }))128}129130#[cfg(test)]131mod tests {132    use super::*;133    use mrlycore::state::guard;134    use std::fs;135136    const PINNED: u64 = 1;137138    fn tiny() -> Config {139        Config {140            max_generations: 16,141            max_segments: 4,142            max_canvas: 15,143            min_tile: 3,144            max_tile: 5,145            min_mask: 3,146            max_mask: 5,147            attempts: 64,148        }149    }150151    fn scratch(name: &str) -> std::path::PathBuf {152        std::env::temp_dir().join(format!("mrlygame_{name}_{}", std::process::id()))153    }154155    #[test]156    fn two_emits_press_identical_bytes() {157        let _g = guard();158        let (a, b) = (scratch("emita"), scratch("emitb"));159        make(&a).unwrap();160        make(&b).unwrap();161        emit_with(&a, PINNED, &tiny()).unwrap();162        emit_with(&b, PINNED, &tiny()).unwrap();163        for name in ["quest.json", "audio.wav"] {164            let x = fs::read(a.join(name)).unwrap();165            let y = fs::read(b.join(name)).unwrap();166            assert_eq!(x, y, "{name} drifted between emits");167        }168        for folder in ["frames", "heatmap"] {169            let x = fs::read(a.join(folder).join("0001.png")).unwrap();170            let y = fs::read(b.join(folder).join("0001.png")).unwrap();171            assert_eq!(x, y, "{folder} drifted between emits");172        }173        fs::remove_dir_all(&a).ok();174        fs::remove_dir_all(&b).ok();175    }176177    #[test]178    fn the_emitted_folder_holds_the_whole_product() {179        let _g = guard();180        let dir = scratch("press");181        make(&dir).unwrap();182        let record = emit_with(&dir, PINNED, &tiny()).unwrap();183        let count = record["manifest"]["segments"]184            .as_array()185            .unwrap()186            .iter()187            .map(|n| n.as_u64().unwrap() as usize)188            .sum::<usize>();189        assert!(count > 0);190        for folder in ["frames", "heatmap"] {191            for frame in 1..=count {192                let path = dir.join(folder).join(format!("{frame:04}.png"));193                let bytes = fs::read(&path).unwrap();194                assert_eq!(&bytes[1..4], b"PNG");195            }196            assert!(!dir197                .join(folder)198                .join(format!("{:04}.png", count + 1))199                .exists());200        }201        let wav = fs::read(dir.join("audio.wav")).unwrap();202        assert_eq!(&wav[0..4], b"RIFF");203        assert!(wav.len() > 44 + audio::RATE);204        assert!(wav[44..].iter().any(|&b| b != 0));205        fs::remove_dir_all(&dir).ok();206    }207208    #[test]209    fn the_record_carries_the_assembly_manifest() {210        let _g = guard();211        let dir = scratch("record");212        make(&dir).unwrap();213        let record = emit_with(&dir, PINNED, &tiny()).unwrap();214        assert_eq!(record["v"], json!(1));215        let key = record["key"].as_str().unwrap().to_string();216        assert_eq!(record["name"].as_str(), Some(&*format!("mrly_quest_{key}")));217        assert_eq!(218            record["seed"].as_str().and_then(|s| s.parse::<u64>().ok()),219            Some(PINNED)220        );221        assert!(record["story"]["chapters"].as_array().is_some());222        let manifest = &record["manifest"];223        assert_eq!(manifest["fps"], json!(FPS));224        assert_eq!(manifest["heatmap_fps"], json!(HEATMAP_FPS));225        assert_eq!(manifest["size"], json!(SIZE));226        assert_eq!(manifest["audio"], json!("audio.wav"));227        assert_eq!(manifest["freeze_us"], json!(FREEZE_US));228        assert_eq!(manifest["inter_freeze_us"], json!(INTER_FREEZE_US));229        assert!(manifest["canvas"].as_u64().unwrap() > 0);230        let lengths: Vec<usize> = manifest["segments"]231            .as_array()232            .unwrap()233            .iter()234            .map(|n| n.as_u64().unwrap() as usize)235            .collect();236        let total: usize = lengths.iter().sum();237        for (name, fps) in [("frames", FPS), ("heatmap", HEATMAP_FPS)] {238            let entries = manifest[name].as_array().unwrap();239            assert_eq!(entries.len(), total + lengths.len() + 1);240            assert_eq!(entries[0]["us"].as_u64(), Some(FREEZE_US));241            assert_eq!(entries[1]["us"].as_u64(), Some(1_000_000 / fps as u64));242            assert_eq!(243                entries.last().unwrap()["frame"].as_u64(),244                Some(total as u64)245            );246        }247        fs::remove_dir_all(&dir).ok();248    }249250    #[test]251    fn a_top_bit_seed_survives_the_record_and_replays() {252        let _g = guard();253        let seed = u64::MAX - 41;254        let (a, b) = (scratch("bigseeda"), scratch("bigseedb"));255        make(&a).unwrap();256        make(&b).unwrap();257        let record = emit_with(&a, seed, &tiny()).unwrap();258        assert_eq!(record["seed"].as_str(), Some(seed.to_string().as_str()));259        let stored: u64 = record["seed"].as_str().unwrap().parse().unwrap();260        emit_with(&b, stored, &tiny()).unwrap();261        let x = fs::read(a.join("quest.json")).unwrap();262        let y = fs::read(b.join("quest.json")).unwrap();263        assert_eq!(x, y, "the recorded seed replayed a different quest");264        fs::remove_dir_all(&a).ok();265        fs::remove_dir_all(&b).ok();266    }267268    #[test]269    fn timelines_hold_the_freeze_policy() {270        let frames = timeline(&[2, 3], 125_000);271        assert_eq!(frames.len(), 2 + 3 + 3);272        assert_eq!(frames[0], json!({ "frame": 1, "us": FREEZE_US }));273        assert_eq!(frames[3], json!({ "frame": 2, "us": INTER_FREEZE_US }));274        assert_eq!(275            frames.last().unwrap(),276            &json!({ "frame": 5, "us": FREEZE_US })277        );278        assert!(timeline(&[], 125_000).is_empty());279    }280}