tile.rs

22.7 kB · rust · 602 lines

1use super::{kind, Named};2use mrlycore::errors::{value_error, MrlyError, Result};3use mrlycore::tile::{Design, Group, Source, Tile as Recipe};4use serde::{Deserialize, Serialize};56kind!("tile");78const CODES_2D: [(Design, u128); 10] = [9    (Design::Carpet, 7),10    (Design::Net, 14),11    (Design::Htree, 3),12    (Design::Vtree, 5),13    (Design::Void, 9),14    (Design::Point, 8),15    (Design::Dust, 1),16    (Design::Hline, 12),17    (Design::Vline, 10),18    (Design::Star, 6),19];2021const TOTAL_2D: u128 = 16;2223/// Returns the plane's bang code of a classic design, or None for one outside the plane.24pub fn classic_code(design: Design) -> Option<u128> {25    CODES_2D26        .iter()27        .find(|&&(d, _)| d == design)28        .map(|&(_, code)| code)29}3031fn code_of(source: Source) -> u128 {32    match source {33        Source::Classic(design) => {34            classic_code(design).expect("a 3d-only design has no code in the plane")35        }36        Source::Code(code) => code,37    }38}3940fn is_false(flag: &bool) -> bool {41    !flag42}4344/// One value for every slot of a tile, or one value per slot.45#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]46#[serde(untagged)]47pub enum Slots {48    /// The one value the slots share.49    One(usize),50    /// One value per slot, in slot order.51    Each(Vec<usize>),52}5354impl Slots {55    fn is_still(&self) -> bool {56        match self {57            Slots::One(turn) => *turn == 0,58            Slots::Each(turns) => turns.iter().all(|&turn| turn == 0),59        }60    }61    fn one(&self, what: &str) -> Result<usize> {62        match self {63            Slots::One(value) => Ok(*value),64            Slots::Each(_) => value_error(format!("a one-slot tile wants one {what}, not a list.")),65        }66    }67    fn each(&self, count: usize, what: &str) -> Result<Vec<usize>> {68        match self {69            Slots::One(0) if what == "turn" => Ok(vec![0; count]),70            Slots::One(_) => value_error(format!("a {count}-slot tile wants a {what} per slot.")),71            Slots::Each(values) if values.len() == count => Ok(values.clone()),72            Slots::Each(values) => value_error(format!(73                "a {count}-slot tile wants {count} {what}s, not {}.",74                values.len()75            )),76        }77    }78}7980impl Default for Slots {81    fn default() -> Slots {82        Slots::One(0)83    }84}8586/// A tile recipe folded to its one canonical object.87///88/// The key that carries the codes says the group: `code` is one design flat or, with `level`, raised89/// to a power; `magic` is a list of letters; `special` is one mask code over a factor; `mosaic` is90/// three codes behind a tree mask. Classics fold to their codes, a lone anti folds into `invert`,91/// and a level of one folds away, so aliases that draw one picture share one name.92///93/// ```94/// use mrlymath::name::{Named, Tile};95/// let carpet = Tile::from_json(r#"{"kind":"tile","code":7,"side":3,"level":2}"#).unwrap();96/// assert_eq!(carpet.recipe().unwrap().width, 9);97/// assert_eq!(Tile::of(&carpet.recipe().unwrap()), carpet);98/// ```99#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]100#[serde(deny_unknown_fields)]101pub struct Tile {102    /// The kind word.103    pub kind: Kind,104    /// The one design of a flat or fractal tile.105    #[serde(default, skip_serializing_if = "Option::is_none")]106    pub code: Option<u128>,107    /// The mask code of a special tile.108    #[serde(default, skip_serializing_if = "Option::is_none")]109    pub special: Option<u128>,110    /// The letters of a magic tile, first letter outermost.111    #[serde(default, skip_serializing_if = "Vec::is_empty")]112    pub magic: Vec<u128>,113    /// The three codes of a mosaic tile.114    #[serde(default, skip_serializing_if = "Vec::is_empty")]115    pub mosaic: Vec<u128>,116    /// The side of the mask of a special or mosaic tile.117    #[serde(default, skip_serializing_if = "Option::is_none")]118    pub factor: Option<usize>,119    /// The side each slot renders at, one per letter for a magic tile.120    pub side: Slots,121    /// The power a fractal tile is raised to, absent at one.122    #[serde(default, skip_serializing_if = "Option::is_none")]123    pub level: Option<usize>,124    /// The quarter turns of each slot, absent when nothing turns.125    #[serde(default, skip_serializing_if = "Slots::is_still")]126    pub turn: Slots,127    /// Whether each slot swaps fill and void, absent when none does.128    #[serde(default, skip_serializing_if = "Vec::is_empty")]129    pub anti: Vec<bool>,130    /// Whether a special tile flips its mask.131    #[serde(default, skip_serializing_if = "is_false")]132    pub flip: bool,133    /// Whether the finished tile inverts.134    #[serde(default, skip_serializing_if = "is_false")]135    pub invert: bool,136}137138fn blank() -> Tile {139    Tile {140        kind: Kind,141        code: None,142        special: None,143        magic: Vec::new(),144        mosaic: Vec::new(),145        factor: None,146        side: Slots::One(0),147        level: None,148        turn: Slots::One(0),149        anti: Vec::new(),150        flip: false,151        invert: false,152    }153}154155fn turns(recipe: &Recipe) -> Slots {156    Slots::Each(recipe.rotations.iter().map(|r| r % 4).collect())157}158159fn plane(code: u128) -> Result<u128> {160    if code >= TOTAL_2D {161        return value_error(format!("code {code} is not in the plane (0..15)."));162    }163    Ok(code)164}165166impl Tile {167    /// Folds a recipe to its name.168    pub fn of(recipe: &Recipe) -> Tile {169        let codes: Vec<u128> = recipe.sources.iter().map(|&s| code_of(s)).collect();170        let mut name = blank();171        name.invert = recipe.invert;172        match recipe.group {173            Group::General | Group::Fractal => {174                name.code = Some(codes[0]);175                name.side = Slots::One(recipe.numbers[0]);176                name.level = (recipe.group == Group::Fractal && recipe.levels[0] != 1)177                    .then_some(recipe.levels[0]);178                name.turn = Slots::One(recipe.rotations[0] % 4);179                name.invert = recipe.anti[0] ^ recipe.invert;180            }181            Group::Magic => {182                name.magic = codes;183                name.side = Slots::Each(recipe.numbers.clone());184                name.turn = turns(recipe);185                name.anti = recipe.anti.clone();186            }187            Group::Special => {188                name.special = Some(codes[0]);189                name.factor = Some(recipe.factor);190                name.side = Slots::One(recipe.numbers[0]);191                name.turn = Slots::One(recipe.rotations[0] % 4);192                name.flip = recipe.flip;193            }194            Group::Mosaic => {195                name.mosaic = codes;196                name.factor = Some(recipe.factor);197                name.side = Slots::One(recipe.numbers[0]);198                name.turn = turns(recipe);199                name.anti = recipe.anti.clone();200            }201        }202        name.fold()203    }204    fn fold(mut self) -> Tile {205        if self.turn.is_still() {206            self.turn = Slots::One(0);207        }208        if self.anti.iter().all(|&a| !a) {209            self.anti.clear();210        }211        self212    }213    fn group(&self) -> Result<Group> {214        let carried = [215            (self.code.is_some(), Group::General),216            (self.special.is_some(), Group::Special),217            (!self.magic.is_empty(), Group::Magic),218            (!self.mosaic.is_empty(), Group::Mosaic),219        ];220        let mut groups = carried221            .iter()222            .filter(|(held, _)| *held)223            .map(|&(_, group)| group);224        let (Some(group), None) = (groups.next(), groups.next()) else {225            return value_error("a tile carries exactly one of code, special, magic or mosaic.");226        };227        if group == Group::General && self.level.is_some_and(|level| level != 1) {228            return Ok(Group::Fractal);229        }230        if group != Group::General && self.level.is_some() {231            return value_error("level is fractal only.");232        }233        Ok(group)234    }235    fn anti(&self, count: usize) -> Result<Vec<bool>> {236        match self.anti.len() {237            0 => Ok(vec![false; count]),238            n if n == count => Ok(self.anti.clone()),239            n => value_error(format!(240                "a {count}-slot tile wants {count} anti flags, not {n}."241            )),242        }243    }244    fn lone(&self, count: usize) -> Result<()> {245        if !self.anti.is_empty() {246            return value_error("anti folds into invert on a one-slot tile.");247        }248        if count == 1 && self.factor.is_some() {249            return value_error("factor is special or mosaic only.");250        }251        Ok(())252    }253    /// Builds the recipe the name folds, resized and checked, or an error naming what fails.254    pub fn recipe(&self) -> Result<Recipe> {255        let group = self.group()?;256        let mut recipe = Recipe::new(group);257        recipe.invert = self.invert;258        match group {259            Group::General | Group::Fractal => {260                self.lone(1)?;261                if self.flip {262                    return value_error("flip is special only.");263                }264                recipe.sources = vec![Source::Code(plane(self.code.expect("a code"))?)];265                recipe.numbers = vec![self.side.one("side")?];266                recipe.levels = vec![self.level.unwrap_or(1)];267                recipe.rotations = vec![self.turn.one("turn")?];268                recipe.anti = vec![false];269            }270            Group::Magic => {271                let count = self.magic.len();272                if self.flip || self.factor.is_some() {273                    return value_error("a magic tile carries no flip or factor.");274                }275                recipe.sources = self276                    .magic277                    .iter()278                    .map(|&code| Ok(Source::Code(plane(code)?)))279                    .collect::<Result<Vec<Source>>>()?;280                recipe.numbers = self.side.each(count, "side")?;281                recipe.levels = vec![1; count];282                recipe.rotations = self.turn.each(count, "turn")?;283                recipe.anti = self.anti(count)?;284            }285            Group::Special => {286                if !self.anti.is_empty() {287                    return value_error("anti is dead on a special tile.");288                }289                let Some(factor) = self.factor else {290                    return value_error("a special tile wants its factor.");291                };292                recipe.sources = vec![Source::Code(plane(self.special.expect("a mask code"))?)];293                recipe.factor = factor;294                recipe.numbers = vec![self.side.one("side")?];295                recipe.levels = vec![1];296                recipe.rotations = vec![self.turn.one("turn")?];297                recipe.anti = vec![false];298                recipe.flip = self.flip;299            }300            Group::Mosaic => {301                if self.flip {302                    return value_error("flip is special only.");303                }304                let Some(factor) = self.factor else {305                    return value_error("a mosaic tile wants its factor.");306                };307                recipe.sources = self308                    .mosaic309                    .iter()310                    .map(|&code| Ok(Source::Code(plane(code)?)))311                    .collect::<Result<Vec<Source>>>()?;312                recipe.factor = factor;313                recipe.numbers = vec![self.side.one("side")?; 3];314                recipe.levels = vec![1; 3];315                recipe.rotations = self.turn.each(3, "turn")?;316                recipe.anti = self.anti(3)?;317            }318        }319        recipe.resize();320        recipe321            .check()322            .map_err(|note| MrlyError::Value(format!("tile fails its check: {note}.")))?;323        Ok(recipe)324    }325}326327impl Named for Tile {328    const KIND: &'static str = "tile";329    const LISTS: &'static [&'static str] = &["magic", "mosaic", "anti"];330    fn checked(self) -> Result<Tile> {331        Ok(Tile::of(&self.recipe()?))332    }333}334335#[cfg(test)]336mod tests {337    use super::*;338    use crate::two::designs;339    use crate::two::tile as tile2d;340    use mrlycore::state::{guard, seed};341    use mrlycore::tile::{Catalog, Parity};342343    const CARPET: &str = r#"{"kind":"tile","code":7,"side":3,"level":2}"#;344    const GENERAL: &str = r#"{"kind":"tile","code":3,"side":5,"turn":1,"invert":true}"#;345    const MAGIC: &str = r#"{"kind":"tile","magic":[7,14],"side":[3,5],"turn":[0,2],"anti":[false,true],"invert":true}"#;346    const SPECIAL: &str = r#"{"kind":"tile","special":5,"factor":3,"side":5,"flip":true}"#;347    const MOSAIC: &str = r#"{"kind":"tile","mosaic":[7,14,5],"factor":3,"side":3,"turn":[0,1,0],"anti":[false,false,true],"invert":true}"#;348349    fn built(recipe: &Recipe) -> crate::two::Cell2d {350        tile2d::build(recipe).unwrap()351    }352    fn fractal(design: Design) -> Recipe {353        let mut recipe = Recipe::new(Group::Fractal);354        recipe.sources = vec![Source::Classic(design)];355        recipe.numbers = vec![3];356        recipe.levels = vec![2];357        recipe.rotations = vec![0];358        recipe.anti = vec![false];359        recipe.resize();360        recipe361    }362363    #[test]364    fn classic_codes_match_their_renders() {365        for (design, code) in CODES_2D {366            let by_name = designs::create(code, 3, 1, 0, 2).unwrap();367            let by_classic = match design {368                Design::Carpet => designs::carpet(3, 1).unwrap(),369                Design::Net => designs::net(3, 1).unwrap(),370                Design::Htree => designs::htree(3, 1).unwrap(),371                Design::Vtree => designs::vtree(3, 1).unwrap(),372                Design::Void => designs::void(3, 1).unwrap(),373                Design::Point => designs::point(3, 1).unwrap(),374                Design::Dust => designs::dust(3, 1).unwrap(),375                Design::Hline => designs::hline(3, 1).unwrap(),376                Design::Vline => designs::vline(3, 1).unwrap(),377                Design::Star => designs::star(3, 1).unwrap(),378                _ => unreachable!(),379            };380            assert_eq!(by_name, by_classic, "{}", design.name());381        }382    }383    #[test]384    fn example_names_hold_verbatim() {385        assert_eq!(Tile::of(&fractal(Design::Carpet)).to_json(), CARPET);386        let mut general = Recipe::new(Group::General);387        general.sources = vec![Source::Code(3)];388        general.numbers = vec![5];389        general.levels = vec![1];390        general.rotations = vec![1];391        general.anti = vec![false];392        general.invert = true;393        general.resize();394        assert_eq!(Tile::of(&general).to_json(), GENERAL);395        let mut magic = Recipe::new(Group::Magic);396        magic.sources = vec![Source::Classic(Design::Carpet), Source::Code(14)];397        magic.numbers = vec![3, 5];398        magic.levels = vec![1, 1];399        magic.rotations = vec![0, 2];400        magic.anti = vec![false, true];401        magic.invert = true;402        magic.resize();403        assert_eq!(Tile::of(&magic).to_json(), MAGIC);404        let mut special = Recipe::new(Group::Special);405        special.sources = vec![Source::Classic(Design::Vtree)];406        special.factor = 3;407        special.numbers = vec![5];408        special.levels = vec![1];409        special.rotations = vec![0];410        special.anti = vec![true];411        special.flip = true;412        special.resize();413        assert_eq!(Tile::of(&special).to_json(), SPECIAL);414        let mut mosaic = Recipe::new(Group::Mosaic);415        mosaic.sources = vec![Source::Code(7), Source::Code(14), Source::Code(5)];416        mosaic.factor = 3;417        mosaic.numbers = vec![3, 3, 3];418        mosaic.levels = vec![1, 1, 1];419        mosaic.rotations = vec![0, 1, 0];420        mosaic.anti = vec![false, false, true];421        mosaic.invert = true;422        mosaic.resize();423        assert_eq!(Tile::of(&mosaic).to_json(), MOSAIC);424    }425    #[test]426    fn the_views_hold_verbatim() {427        let magic = Tile::from_json(MAGIC).unwrap();428        assert_eq!(429            magic.to_url(),430            "/tile?magic=7,14&side=3,5&turn=0,2&anti=false,true&invert=true"431        );432        assert_eq!(433            magic.to_file(),434            "tile_magic=[7,14]_side=[3,5]_turn=[0,2]_anti=[false,true]_invert=true"435        );436        assert_eq!(437            magic.to_mrly(),438            "tile magic [7 14], side [3 5], turn [0 2], anti [false true], invert"439        );440        let carpet = Tile::from_json(CARPET).unwrap();441        assert_eq!(carpet.to_url(), "/tile?code=7&side=3&level=2");442        assert_eq!(carpet.to_file(), "tile_code=7_side=3_level=2");443        assert_eq!(carpet.to_mrly(), "tile code 7, side 3, level 2");444        assert_eq!(445            Tile::from_json(SPECIAL).unwrap().to_mrly(),446            "tile special 5, factor 3, side 5, flip"447        );448    }449    #[test]450    fn parsed_tiles_pass_check_and_build() {451        for name in [CARPET, GENERAL, MAGIC, SPECIAL, MOSAIC] {452            let tile = Tile::from_json(name).unwrap();453            let recipe = tile.recipe().unwrap();454            assert_eq!(recipe.check(), Ok(()));455            assert_eq!(tile.to_json(), name);456            assert_eq!(Tile::from_url(&tile.to_url()).unwrap(), tile, "{name}");457            assert_eq!(Tile::from_file(&tile.to_file()).unwrap(), tile, "{name}");458            let cell = built(&recipe);459            assert_eq!(cell.width(), recipe.width);460        }461    }462    #[test]463    fn a_level_of_one_folds_to_the_flat_tile() {464        let mut flat = fractal(Design::Carpet);465        flat.levels = vec![1];466        flat.resize();467        let name = Tile::of(&flat);468        assert_eq!(name.to_json(), r#"{"kind":"tile","code":7,"side":3}"#);469        let spelt = Tile::from_json(r#"{"kind":"tile","code":7,"side":3,"level":1}"#).unwrap();470        assert_eq!(spelt, name);471        let recipe = spelt.recipe().unwrap();472        assert_eq!(recipe.group, Group::General);473        assert_eq!(built(&recipe), built(&flat));474    }475    #[test]476    fn anti_invert_pairs_share_one_name_and_one_picture() {477        let mut plain = fractal(Design::Carpet);478        let mut folded = plain.clone();479        folded.anti = vec![true];480        folded.invert = true;481        assert_eq!(Tile::of(&plain), Tile::of(&folded));482        assert_eq!(built(&plain), built(&folded));483        plain.invert = true;484        let mut alias = plain.clone();485        alias.anti = vec![true];486        alias.invert = false;487        assert_eq!(Tile::of(&plain), Tile::of(&alias));488        assert_eq!(built(&plain), built(&alias));489        assert_eq!(490            Tile::of(&plain).to_json(),491            r#"{"kind":"tile","code":7,"side":3,"level":2,"invert":true}"#492        );493    }494    #[test]495    fn classics_and_codes_share_one_name() {496        let by_classic = fractal(Design::Net);497        let mut by_code = by_classic.clone();498        by_code.sources = vec![Source::Code(14)];499        assert_eq!(Tile::of(&by_classic), Tile::of(&by_code));500        assert_eq!(built(&by_classic), built(&by_code));501    }502    #[test]503    fn dead_special_anti_folds_away() {504        let mut special = Recipe::new(Group::Special);505        special.sources = vec![Source::Code(5)];506        special.factor = 3;507        special.numbers = vec![3];508        special.levels = vec![1];509        special.rotations = vec![0];510        special.anti = vec![true];511        special.resize();512        let parsed = Tile::from_json(&Tile::of(&special).to_json())513            .unwrap()514            .recipe()515            .unwrap();516        assert_eq!(parsed.anti, vec![false]);517        assert_eq!(built(&special), built(&parsed));518    }519    #[test]520    fn a_spelt_default_folds_to_the_canonical_string() {521        let spelt = r#"{"kind":"tile","side":[3,5],"magic":[7,14],"turn":[0,0],"anti":[false,false],"invert":false}"#;522        let tile = Tile::from_json(spelt).unwrap();523        assert_eq!(524            tile.to_json(),525            r#"{"kind":"tile","magic":[7,14],"side":[3,5]}"#526        );527        assert_eq!(tile.turn, Slots::One(0));528        assert!(tile.anti.is_empty());529    }530    #[test]531    fn only_a_tile_that_draws_parses() {532        for bad in [533            r#"{"kind":"tile","code":7,"side":3,"turn":4}"#,534            r#"{"kind":"tile","code":16,"side":3}"#,535            r#"{"kind":"tile","code":7,"side":99}"#,536            r#"{"kind":"tile","code":7,"side":3,"flip":true}"#,537            r#"{"kind":"tile","code":7,"side":3,"anti":[true]}"#,538            r#"{"kind":"tile","code":7,"side":3,"factor":3}"#,539            r#"{"kind":"tile","code":7,"side":[3]}"#,540            r#"{"kind":"tile","code":7,"side":3,"level":7}"#,541            r#"{"kind":"tile","code":7,"magic":[7,14],"side":3}"#,542            r#"{"kind":"tile","side":3}"#,543            r#"{"kind":"tile","sparkle":7,"side":3}"#,544            r#"{"kind":"tile","magic":[7],"side":[3]}"#,545            r#"{"kind":"tile","magic":[7,14],"side":3}"#,546            r#"{"kind":"tile","magic":[7,14],"side":[3,5],"anti":[true]}"#,547            r#"{"kind":"tile","magic":[7,14],"side":[3,5],"level":2}"#,548            r#"{"kind":"tile","mosaic":[7,14,5],"factor":3,"side":[3,3,3]}"#,549            r#"{"kind":"tile","mosaic":[7,14],"factor":3,"side":3}"#,550            r#"{"kind":"tile","mosaic":[7,14,5],"side":3}"#,551            r#"{"kind":"tile","special":5,"side":5}"#,552            r#"{"kind":"tile","special":5,"factor":3,"side":5,"anti":[true]}"#,553            r#"{"kind":"bang","dim":2,"code":7}"#,554            "tile code 7, side 3, level 2",555            "tile_code=7_side=3_level=2",556        ] {557            assert!(Tile::from_json(bad).is_err(), "{bad}");558        }559    }560    #[test]561    fn seeded_tiles_round_trip() {562        let _guard = guard();563        let config = tile2d::Config {564            catalog: Catalog::Universe,565            min_size: 2,566            max_size: 64,567            parity: Parity::Both,568            ..tile2d::Config::default()569        };570        for s in 0..300 {571            seed(s);572            let recipe = tile2d::create(&config).unwrap();573            let name = Tile::of(&recipe);574            let text = name.to_json();575            let parsed = Tile::from_json(&text).unwrap();576            assert_eq!(parsed.to_json(), text, "seed {s}");577            assert_eq!(Tile::from_url(&name.to_url()).unwrap(), name, "seed {s}");578            assert_eq!(Tile::from_file(&name.to_file()).unwrap(), name, "seed {s}");579            let back = parsed.recipe().unwrap();580            assert_eq!(back.check(), Ok(()), "seed {s}");581            assert_eq!(built(&back), built(&recipe), "seed {s}");582        }583    }584    #[test]585    fn seeded_classic_tiles_round_trip() {586        let _guard = guard();587        let config = tile2d::Config {588            min_size: 2,589            max_size: 64,590            parity: Parity::Both,591            ..tile2d::Config::default()592        };593        for s in 0..300 {594            seed(s);595            let recipe = tile2d::create(&config).unwrap();596            let text = Tile::of(&recipe).to_json();597            let parsed = Tile::from_json(&text).unwrap();598            assert_eq!(parsed.to_json(), text, "seed {s}");599            assert_eq!(built(&parsed.recipe().unwrap()), built(&recipe), "seed {s}");600        }601    }602}