use serde::{Deserialize, Serialize};
use crate::descriptor::Descriptor;
use crate::placement::ir::Guard;
pub const MAP_VERSION: u32 = 9;
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Map {
pub version: u32,
#[serde(default)]
pub name: String,
pub origin: (f32, f32, f32),
#[serde(default = "default_bounds")]
pub bounds: (f32, f32, f32),
pub bash: Option<String>,
pub placements: Vec<Placed>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub stamps: Vec<crate::composition::Stamped>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub runs: Vec<crate::run::RunPlaced>,
#[serde(default)]
pub locations: Vec<Location>,
#[serde(default)]
pub note: Option<String>,
#[serde(default)]
pub enclosure: Enclosure,
}
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Enclosure {
pub wish: f32,
pub weight: u32,
}
impl Default for Enclosure {
fn default() -> Self {
Enclosure { wish: 0.25, weight: 200 }
}
}
impl Default for Map {
fn default() -> Self {
Map {
version: MAP_VERSION,
name: String::new(),
origin: (0.0, 0.0, 0.0),
bounds: default_bounds(),
bash: None,
placements: Vec::new(),
stamps: Vec::new(),
runs: Vec::new(),
locations: Vec::new(),
note: None,
enclosure: Enclosure::default(),
}
}
}
fn lift_is_zero(v: &f32) -> bool {
*v == 0.0
}
fn tip_is_zero(v: &(u8, u8)) -> bool {
*v == (0, 0)
}
fn paint_is_zero(v: &i8) -> bool {
*v == 0
}
fn not_owned(v: &bool) -> bool {
!*v
}
fn absent<T>(v: &Option<T>) -> bool {
v.is_none()
}
fn default_bounds() -> (f32, f32, f32) {
(10.0, 4.0, 10.0)
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct Placed {
pub id: String,
pub descriptor: String,
pub at: (f32, f32),
pub yaw: f32,
#[serde(default, skip_serializing_if = "lift_is_zero")]
pub lift: f32,
#[serde(default, skip_serializing_if = "tip_is_zero")]
pub tip: (u8, u8),
#[serde(default, skip_serializing_if = "absent")]
pub on: Option<String>,
#[serde(default, skip_serializing_if = "not_owned")]
pub owned: bool,
#[serde(default, skip_serializing_if = "absent")]
pub owned_because: Option<String>,
#[serde(default, skip_serializing_if = "absent")]
pub patch: Option<Descriptor>,
#[serde(default, skip_serializing_if = "paint_is_zero")]
pub paint: i8,
#[serde(default, skip_serializing_if = "absent")]
pub note: Option<String>,
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
pub family: std::collections::BTreeMap<String, String>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Location {
pub id: String,
pub props: Vec<String>,
pub interactions: Vec<Interaction>,
#[serde(default)]
pub note: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Interaction {
pub verb: String,
pub roles: Vec<RoleSlot>,
#[serde(default)]
pub guard: Option<Guard>,
pub effects: Vec<Effect>,
#[serde(default)]
pub note: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RoleSlot {
pub name: String,
pub kind: RoleKind,
pub min: u8,
pub max: u8,
#[serde(default)]
pub socket_role: Option<String>,
#[serde(default)]
pub requires: Vec<String>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub enum RoleKind {
Main,
Supporting,
Extra,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub enum Effect {
Restore { drive: String, rate: f32 },
Drain { drive: String, rate: f32 },
}
fn compact<T: Serialize>(
value: &T,
config: &ron::ser::PrettyConfig,
what: &str,
) -> Result<String, String> {
ron::ser::to_string_pretty(value, config.clone())
.map_err(|e| format!("map: serialize `{what}`: {e}"))
}
fn minimal_patch(patch: &Descriptor, full: &str, default: &str) -> String {
let short = prune(full, default).unwrap_or_else(|| "()".to_owned());
match ron::from_str::<Descriptor>(&short) {
Ok(back) if back == *patch => short,
_ => full.to_owned(),
}
}
fn prune(value: &str, default: &str) -> Option<String> {
if value == default {
return None;
}
let (Some(fields), Some(defaults)) = (struct_fields(value), struct_fields(default)) else {
return Some(value.to_owned());
};
let kept: Vec<String> = fields
.iter()
.filter_map(|(name, field)| {
match defaults.iter().find(|(other, _)| other == name) {
Some((_, was)) => prune(field, was),
None => Some((*field).to_owned()),
}
.map(|written| format!("{name}: {written}"))
})
.collect();
if kept.is_empty() {
return Some(value.to_owned());
}
Some(format!("({})", kept.join(", ")))
}
fn struct_fields(text: &str) -> Option<Vec<(&str, &str)>> {
let inner = text.strip_prefix('(')?.strip_suffix(')')?;
let items = split_top(inner)?;
if items.is_empty() {
return None;
}
items
.into_iter()
.map(|item| {
let (name, value) = item.split_once(": ")?;
let ident = !name.is_empty()
&& name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
&& !name.starts_with(|c: char| c.is_ascii_digit());
ident.then_some((name, value))
})
.collect()
}
fn split_top(inner: &str) -> Option<Vec<&str>> {
let mut items = Vec::new();
let (mut depth, mut start, mut in_string, mut escaped) = (0usize, 0usize, false, false);
for (i, c) in inner.char_indices() {
if in_string {
match (escaped, c) {
(true, _) => escaped = false,
(false, '\\') => escaped = true,
(false, '"') => in_string = false,
_ => {}
}
continue;
}
match c {
'"' => in_string = true,
'(' | '[' | '{' => depth += 1,
')' | ']' | '}' => depth = depth.checked_sub(1)?,
',' if depth == 0 => {
items.push(inner[start..i].trim());
start = i + 1;
}
_ => {}
}
}
if depth != 0 || in_string {
return None;
}
let last = inner[start..].trim();
if !last.is_empty() {
items.push(last);
}
Some(items)
}
fn unescape_apostrophes(text: &str) -> String {
let mut out = String::with_capacity(text.len());
let mut chars = text.chars();
while let Some(c) = chars.next() {
if c != '\\' {
out.push(c);
continue;
}
match chars.next() {
Some('\'') => out.push('\''),
Some(next) => {
out.push('\\');
out.push(next);
}
None => out.push('\\'),
}
}
out
}
impl Map {
pub fn parse(text: &str) -> Result<Map, String> {
let map: Map = ron::from_str(text).map_err(|e| format!("map: {e}"))?;
map.validate()?;
Ok(map)
}
pub fn to_ron(&self) -> Result<String, String> {
let pretty = ron::ser::PrettyConfig::default();
let header = Map { placements: Vec::new(), ..self.clone() };
let mut text = ron::ser::to_string_pretty(&header, pretty.clone())
.map_err(|e| format!("map: serialize: {e}"))?;
if !self.placements.is_empty() {
const EMPTY: &str = "\n placements: [],\n";
let at = text.find(EMPTY).ok_or_else(|| {
"map: serialize: the header has no `placements: [],` line to fill".to_owned()
})?;
let row_config = pretty
.compact_structs(true)
.compact_arrays(true)
.compact_maps(true);
let default_patch = compact(&Descriptor::default(), &row_config, "the default patch")?;
let mut rows = String::from("\n placements: [\n");
for placed in &self.placements {
let row = match &placed.patch {
None => compact(placed, &row_config, &placed.id)?,
Some(patch) => {
let stand_in = Placed {
patch: Some(Descriptor::default()),
..placed.clone()
};
let row = compact(&stand_in, &row_config, &placed.id)?;
let full = compact(patch, &row_config, &placed.id)?;
let written = minimal_patch(patch, &full, &default_patch);
row.replacen(
&format!("patch: Some({default_patch})"),
&format!("patch: Some({written})"),
1,
)
}
};
rows.push_str(" ");
rows.push_str(&row);
rows.push_str(",\n");
}
rows.push_str(" ],\n");
text.replace_range(at..at + EMPTY.len(), &rows);
}
let mut text = unescape_apostrophes(&text);
if !text.ends_with('\n') {
text.push('\n');
}
Ok(text)
}
pub fn expanded(
&self,
compositions: &[crate::composition::Composition],
library: &crate::library::Library,
) -> Result<Map, String> {
if self.stamps.is_empty() && self.runs.is_empty() {
return Ok(self.clone());
}
let mut out = self.clone();
if !self.stamps.is_empty() {
let expansion = crate::composition::expand_map(self, compositions, library)?;
out.placements.extend(expansion.placements);
out.locations.extend(expansion.locations);
}
for r in &self.runs {
let posts = crate::run::lay(&r.run, library)
.map_err(|e| format!("map: run `{}` does not lay: {e}", r.id))?;
for (n, post) in posts.iter().enumerate() {
out.placements.push(Placed {
id: crate::run::post_id(&r.id, n),
descriptor: r.run.descriptor.clone(),
at: post.at,
yaw: post.yaw,
owned: r.owned,
owned_because: r.owned_because.clone(),
..Placed::default()
});
}
}
Ok(out)
}
pub fn placements_to_run(
&mut self,
run_id: &str,
rows: &[String],
library: &crate::library::Library,
) -> Result<(), String> {
let mut picked: Vec<Placed> = Vec::with_capacity(rows.len());
for id in rows {
let row = self
.placements
.iter()
.find(|p| &p.id == id)
.ok_or_else(|| format!("map: there is no placement `{id}` to convert"))?;
picked.push(row.clone());
}
for id in rows {
if self.placements.iter().any(|p| p.on.as_deref() == Some(id) && !rows.contains(&p.id)) {
return Err(format!("map: something rests on `{id}`, so it cannot become part of a run"));
}
if self.locations.iter().any(|l| l.props.contains(id)) {
return Err(format!("map: a location lists `{id}`, so it cannot become part of a run"));
}
}
let (run, owned, owned_because) = crate::run::rows_to_run(&picked, library)?;
let mut next = self.clone();
next.placements.retain(|p| !rows.contains(&p.id));
next.runs.push(crate::run::RunPlaced { id: run_id.to_owned(), run, owned, owned_because });
next.validate()?;
*self = next;
Ok(())
}
pub fn stamp_of<'a>(&self, placement_id: &'a str) -> Option<(&'a str, &'a str)> {
let (stamp, member) = crate::composition::split_stamped_id(placement_id)?;
if !self.stamps.iter().any(|s| s.id == stamp) {
return None;
}
Some((stamp, member))
}
pub fn validate_against(&self, descriptors: &[String]) -> Result<(), String> {
self.validate()?;
if descriptors.is_empty() {
return Ok(());
}
for p in &self.placements {
if !descriptors.iter().any(|d| d == &p.descriptor) {
return Err(format!(
"map: placement `{}` names descriptor `{}`, which does not exist",
p.id, p.descriptor
));
}
}
Ok(())
}
pub fn floor_rect(&self) -> (f32, f32, f32, f32) {
let (hx, hz) = (self.bounds.0 * 0.5, self.bounds.2 * 0.5);
(-hx, -hz, hx, hz)
}
pub fn to_map_space(&self, world_xz: (f32, f32)) -> (f32, f32) {
(world_xz.0 - self.origin.0, world_xz.1 - self.origin.2)
}
pub fn height_span(&self) -> (f32, f32) {
(self.origin.1, self.origin.1 + self.bounds.1)
}
pub fn validate(&self) -> Result<(), String> {
if !crate::naming::is_snake_case(&self.name) {
let suggestion = crate::naming::to_snake_case(&self.name);
return Err(if suggestion.is_empty() {
format!(
"map: `{}` is not a usable name. Names are snake_case โ lowercase letters, \
digits and single underscores, starting with a letter.",
self.name
)
} else {
format!(
"map: `{}` is not snake_case. Call it `{suggestion}`.",
self.name
)
});
}
for (axis, v) in [
("x", self.bounds.0),
("y", self.bounds.1),
("z", self.bounds.2),
] {
if !(v.is_finite() && v > 0.0) {
return Err(format!(
"map: bounds.{axis} is {v}. A map has to enclose something โ a non-positive \
extent makes every placement out of bounds and leaves a flood fill nowhere to \
stop."
));
}
}
if self.version > MAP_VERSION {
return Err(format!(
"map: version {} but this build reads {MAP_VERSION} โ refusing a map written by a \
newer tool. Opening it would mean understanding part of it and writing the rest \
away.",
self.version
));
}
let mut seen: Vec<&str> = Vec::with_capacity(self.placements.len());
for p in &self.placements {
if p.id.is_empty() {
return Err("map: a placement has an empty id".to_owned());
}
if seen.contains(&p.id.as_str()) {
return Err(format!(
"map: placement id `{}` is used twice โ locations reference placements by id, so \
a duplicate makes `props` ambiguous",
p.id
));
}
seen.push(&p.id);
if !p.lift.is_finite() {
return Err(format!(
"map: placement `{}` has lift {} โ a non-finite offset places it nowhere",
p.id, p.lift
));
}
if p.tip.0 > 3 || p.tip.1 > 3 {
return Err(format!(
"map: placement `{}` has tip {:?} โ quarter turns are 0..=3 per axis",
p.id, p.tip
));
}
if p.owned && p.owned_because.as_ref().is_none_or(|r| r.trim().is_empty()) {
return Err(format!(
"map: placement `{}` is owned but says nothing about why. An owned placement \
constrains a generator; in six months only that sentence can say whether it \
still should.",
p.id
));
}
}
for p in &self.placements {
let Some(host) = &p.on else { continue };
if host == &p.id {
return Err(format!(
"map: placement `{}` rests on itself. Its height would be its own height, which \
has no answer.",
p.id
));
}
if !seen.contains(&host.as_str()) {
return Err(format!(
"map: placement `{}` rests on `{host}`, which does not exist. A piece whose host \
is missing has no floor to stand on โ placing it at zero would put it through \
the ground.",
p.id
));
}
}
self.no_stacking_cycles()?;
for st in &self.stamps {
if st.id.is_empty() {
return Err("map: a stamp has an empty id".to_owned());
}
if seen.contains(&st.id.as_str()) {
return Err(format!(
"map: stamp `{}` shares its id with a placement. Every row a stamp expands to is \
named after it, so the two id spaces are one.",
st.id
));
}
if !st.at.0.is_finite() || !st.at.1.is_finite() || !st.yaw.is_finite() {
return Err(format!(
"map: stamp `{}` is at a position that is not a number",
st.id
));
}
if st.owned
&& st
.owned_because
.as_ref()
.is_none_or(|r| r.trim().is_empty())
{
return Err(format!(
"map: stamp `{}` is owned but says nothing about why. An owned stamp constrains a \
generator; in six months only that sentence can say whether it still should.",
st.id
));
}
}
let mut runs_seen: Vec<&str> = Vec::with_capacity(self.runs.len());
for r in &self.runs {
if r.id.is_empty() {
return Err("map: a run has an empty id".to_owned());
}
if seen.contains(&r.id.as_str()) || self.stamps.iter().any(|s| s.id == r.id) {
return Err(format!(
"map: run `{}` shares its id with a placement or a stamp. Every row a run lays \
is named after it, so the id spaces are one.",
r.id
));
}
if runs_seen.contains(&r.id.as_str()) {
return Err(format!(
"map: run id `{}` is used twice โ the rows it lays are named after it",
r.id
));
}
runs_seen.push(&r.id);
if r.owned && r.owned_because.as_ref().is_none_or(|b| b.trim().is_empty()) {
return Err(format!(
"map: run `{}` is owned but says nothing about why. An owned run constrains a \
generator; in six months only that sentence can say whether it still should.",
r.id
));
}
}
let mut stamped: Vec<&str> = Vec::with_capacity(self.stamps.len());
for st in &self.stamps {
if stamped.contains(&st.id.as_str()) {
return Err(format!(
"map: stamp id `{}` is used twice โ the rows it expands to are named after it, so \
a duplicate would produce two rows with one id",
st.id
));
}
stamped.push(&st.id);
}
for loc in &self.locations {
if loc.props.is_empty() {
return Err(format!(
"map: location `{}` governs no props โ it would advertise interactions with \
nothing to perform them on",
loc.id
));
}
for prop in &loc.props {
if !seen.contains(&prop.as_str()) {
return Err(format!(
"map: location `{}` references placement `{prop}`, which does not exist",
loc.id
));
}
}
for i in &loc.interactions {
if i.roles.is_empty() {
return Err(format!(
"map: interaction `{}` in location `{}` has no roles โ nobody could perform it",
i.verb, loc.id
));
}
for r in &i.roles {
if r.max < r.min {
return Err(format!(
"map: role `{}` in `{}`/`{}` has max {} below min {}",
r.name, loc.id, i.verb, r.max, r.min
));
}
if r.kind == RoleKind::Main && r.min == 0 {
return Err(format!(
"map: role `{}` in `{}`/`{}` is Main with min 0. A Main role is what \
gates the scene starting; a Main role nobody has to fill is a \
Supporting role.",
r.name, loc.id, i.verb
));
}
}
}
}
Ok(())
}
fn no_stacking_cycles(&self) -> Result<(), String> {
for start in &self.placements {
let mut seen_ids: Vec<&str> = vec![start.id.as_str()];
let mut at = start;
while let Some(host_id) = &at.on {
let Some(host) = self.placements.iter().find(|q| &q.id == host_id) else {
break;
};
if seen_ids.contains(&host.id.as_str()) {
seen_ids.push(&host.id);
return Err(format!(
"map: these placements rest on each other in a loop: {}. Nothing in the \
chain touches the floor, so none of them has a height.",
seen_ids.join(" โ ")
));
}
seen_ids.push(&host.id);
at = host;
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_map_written_one_row_a_line_reads_back_byte_for_byte() -> Result<(), String> {
let text = r#"(
version: 7,
name: "yard",
origin: (0.0, 0.0, 0.0),
bounds: (300.0, 60.0, 1120.0),
bash: None,
placements: [
(id: "torch_00", descriptor: "town/torch", at: (-137.1617, -94.9464), yaw: 0.0, lift: 9.1441),
(id: "smelter_table", descriptor: "town/table", at: (-66.1, 120.6), yaw: 180.0, owned: true, owned_because: Some("the smith works here")),
(id: "smelter_ingots", descriptor: "dress_ingots_steel", at: (-66.1, 120.6), yaw: 0.0, on: Some("smelter_table")),
(id: "road_0_0", descriptor: "town/road", at: (-109.7293, 0.0), yaw: 90.0, patch: Some((extent: (footprint: Some((4.877, 190.1975)))))),
],
locations: [],
note: Some("A yard, for the writer's test. Positions are metres."),
enclosure: (
wish: 0.25,
weight: 200,
),
)
"#;
let map = Map::parse(text)?;
let written = map.to_ron()?;
assert_eq!(written, text);
assert_eq!(Map::parse(&written)?, map);
Ok(())
}
#[test]
fn a_map_still_carrying_the_moved_lattice_is_refused_by_name() {
let stale = r#"(version: 4, name: "m", origin: (0.0, 0.0, 0.0), face_bands: 2, placements: [], locations: [])"#;
let e = Map::parse(stale).err().unwrap_or_default();
assert!(e.contains("face_bands"), "the refusal names the field: {e}");
let stale = r#"(version: 4, name: "m", origin: (0.0, 0.0, 0.0), snap_divisor: 4, placements: [], locations: [])"#;
let e = Map::parse(stale).err().unwrap_or_default();
assert!(e.contains("snap_divisor"), "the refusal names the field: {e}");
let before =
r#"(version: 2, name: "m", origin: (0.0, 0.0, 0.0), placements: [], locations: [])"#;
Map::parse(before).unwrap_or_else(|e| panic!("a pre-lattice map still opens: {e}"));
}
#[test]
fn a_map_names_a_bash_or_says_none_and_none_is_every_bound_kit() {
let stated =
r#"(version: 6, name: "m", origin: (0.0, 0.0, 0.0), bash: None, placements: [], locations: [])"#;
let m = Map::parse(stated).unwrap_or_else(|e| panic!("{e}"));
assert_eq!(m.bash, None, "`None` is every bound kit, and it is written down");
assert_eq!(Map::default().bash, None, "a new map starts there too");
let silent = r#"(version: 6, name: "m", origin: (0.0, 0.0, 0.0), placements: [], locations: [])"#;
let m = Map::parse(silent).unwrap_or_else(|e| panic!("{e}"));
assert_eq!(m.bash, None, "and omitting it is the same value, not a second meaning");
let named =
r#"(version: 6, name: "m", origin: (0.0, 0.0, 0.0), bash: Some("hub"), placements: [], locations: [])"#;
let m = Map::parse(named).unwrap_or_else(|e| panic!("{e}"));
assert_eq!(m.bash.as_deref(), Some("hub"));
let out = ron::ser::to_string(&Map { bash: Some("hub".into()), ..Map::default() })
.unwrap_or_else(|e| panic!("{e}"));
assert!(out.contains("bash:Some(\"hub\")") || out.contains("bash: Some(\"hub\")"), "{out}");
let out = ron::ser::to_string(&Map::default()).unwrap_or_else(|e| panic!("{e}"));
assert!(out.contains("bash:None") || out.contains("bash: None"), "{out}");
}
fn table_map() -> Map {
Map {
version: MAP_VERSION,
name: "galley".into(),
origin: (0.0, 0.0, 0.0),
bounds: default_bounds(),
bash: None,
stamps: Vec::new(),
placements: vec![
Placed {
id: "table_1".into(),
descriptor: "mess_table".into(),
at: (4.0, 4.0),
yaw: 0.0,
..Placed::default()
},
Placed {
id: "stool_1".into(),
descriptor: "stool".into(),
at: (4.0, 3.0),
yaw: 180.0,
..Placed::default()
},
],
locations: vec![Location {
id: "galley_table".into(),
props: vec!["table_1".into(), "stool_1".into()],
note: Some("the galley's near table".into()),
interactions: vec![Interaction {
verb: "eat".into(),
roles: vec![RoleSlot {
name: "diner".into(),
kind: RoleKind::Main,
min: 1,
max: 4,
socket_role: Some("diner".into()),
requires: vec!["eat".into()],
}],
guard: None,
effects: vec![Effect::Restore {
drive: "stamina".into(),
rate: 0.2,
}],
note: None,
}],
}],
note: Some("a galley, for the schema tests".into()),
..Default::default()
}
}
#[test]
fn a_table_and_its_stools_are_one_affordance() {
let m = table_map();
m.validate().expect("valid");
assert_eq!(m.locations[0].props.len(), 2);
assert_eq!(m.locations[0].interactions.len(), 1);
}
#[test]
fn a_map_from_a_newer_schema_is_refused_not_migrated() {
let mut m = table_map();
m.version = MAP_VERSION + 1;
let err = m.validate().expect_err("must refuse");
assert!(err.contains("newer tool"), "{err}");
}
#[test]
fn a_map_from_before_a_field_existed_still_loads() {
let mut m = table_map();
m.version = MAP_VERSION - 1;
m.validate()
.expect("an older map means what it always meant");
assert!(m.stamps.is_empty());
}
#[test]
fn a_location_cannot_reference_a_placement_that_is_not_there() {
let mut m = table_map();
m.locations[0].props.push("ghost_chair".into());
let err = m.validate().expect_err("must refuse");
assert!(err.contains("ghost_chair"), "{err}");
}
#[test]
fn duplicate_placement_ids_are_refused() {
let mut m = table_map();
m.placements[1].id = "table_1".into();
assert!(m.validate().is_err());
}
#[test]
fn an_owned_placement_must_say_why() {
let mut m = table_map();
m.placements[0].owned = true;
let err = m.validate().expect_err("must refuse a reasonless lock");
assert!(err.contains("why"), "{err}");
m.placements[0].owned_because = Some("the cell block's only entrance".into());
m.validate().expect("a reason satisfies it");
}
#[test]
fn a_main_role_nobody_must_fill_is_refused() {
let mut m = table_map();
m.locations[0].interactions[0].roles[0].min = 0;
let err = m.validate().expect_err("must refuse");
assert!(err.contains("Supporting"), "{err}");
}
#[test]
fn a_placement_naming_an_unknown_descriptor_is_refused() {
let m = table_map();
let known = vec!["mess_table".to_string()];
let err = m.validate_against(&known).expect_err("stool is unknown");
assert!(err.contains("stool"), "{err}");
let known = vec!["mess_table".to_string(), "stool".to_string()];
m.validate_against(&known).expect("both known");
}
#[test]
fn the_map_round_trips_through_ron() {
let m = table_map();
let text =
ron::ser::to_string_pretty(&m, ron::ser::PrettyConfig::default()).expect("serializes");
assert_eq!(Map::parse(&text).expect("parses"), m);
}
#[test]
fn a_default_field_is_not_written_and_nothing_is_lost() {
let mut m = Map { name: "sparse".to_owned(), ..Map::default() };
for i in 0..20 {
m.placements.push(Placed {
id: format!("piece_{i}"),
descriptor: "kit/fence".to_owned(),
at: (i as f32, 0.0),
..Placed::default()
});
}
let text =
ron::ser::to_string_pretty(&m, ron::ser::PrettyConfig::default()).expect("serializes");
for padding in [
"on: None",
"owned: false",
"owned_because: None",
"patch: None",
] {
assert!(
!text.contains(padding),
"`{padding}` is a default and is still being written {} times; \
the file is padded with facts its reader already assumes:\n{text}",
text.matches(padding).count()
);
}
assert_eq!(
text.matches("note:").count(),
1,
"only the map's own header note is written; 20 placement rows must not say `note: None`"
);
assert_eq!(
Map::parse(&text).expect("the sparse file still parses"),
m,
"dropping the defaults must not change the map that comes back"
);
}
#[test]
fn a_stated_field_is_still_written() {
let mut m = Map { name: "stated".to_owned(), ..Map::default() };
m.placements.push(Placed {
id: "owned_1".to_owned(),
descriptor: "kit/gate".to_owned(),
at: (1.0, 2.0),
yaw: 90.0,
on: None,
owned: true,
owned_because: Some("the gate the level starts at".to_owned()),
note: Some("swings both ways".to_owned()),
paint: 3,
..Placed::default()
});
let text =
ron::ser::to_string_pretty(&m, ron::ser::PrettyConfig::default()).expect("serializes");
for stated in [
"owned: true",
"the gate the level starts at",
"swings both ways",
"paint: 3",
] {
assert!(
text.contains(stated),
"`{stated}` is a stated value and must be written:\n{text}"
);
}
assert_eq!(Map::parse(&text).expect("parses"), m);
}
#[test]
fn prose_survives_an_ordinary_serializer_because_it_is_a_field() {
let mut m = table_map();
m.placements[0].note = Some("the slab the specimen goes on".into());
let text =
ron::ser::to_string_pretty(&m, ron::ser::PrettyConfig::default()).expect("serializes");
for prose in [
"a galley, for the schema tests",
"the slab the specimen goes on",
] {
assert!(
text.contains(prose),
"`{prose}` was lost by the serializer:\n{text}"
);
}
let back = Map::parse(&text).expect("parses");
assert_eq!(
back, m,
"a note must survive the round trip, not just the write"
);
assert_eq!(
back.placements[0].note.as_deref(),
Some("the slab the specimen goes on")
);
}
#[test]
fn a_note_may_contain_anything_prose_contains() {
for prose in [
r#"see docs/ui.md ยง5 // and the "kit" notes"#,
"the galley's near table",
"a\nnote\nover several lines",
] {
let mut m = table_map();
m.note = Some(prose.to_owned());
let text = ron::ser::to_string_pretty(&m, ron::ser::PrettyConfig::default())
.expect("serializes");
assert_eq!(
Map::parse(&text).expect("parses").note.as_deref(),
Some(prose),
"prose did not survive:\n{text}"
);
}
}
#[test]
fn a_note_is_optional_everywhere() {
let text = r#"(
version: 1,
name: "bare",
origin: (0.0, 0.0, 0.0),
placements: [
( id: "a", descriptor: "crate", at: (1.0, 1.0), yaw: 0.0 ),
],
)"#;
let m = Map::parse(text).expect("a map with no notes must parse");
assert_eq!(m.note, None);
assert_eq!(m.placements[0].note, None);
m.validate().expect("and validate");
}
}