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}