use super::{
bounded_identifier, bounded_text, maximum_document_bytes,
rows::{
anchor_columns, anchor_row, chunk_coordinate, definition_columns, definition_row,
disagrees, identifier, integer, layer, plane_columns, plane_row, zone_columns, zone_row,
},
stored_document, stored_stairwell, utf8_column,
};
use crate::{Error, ErrorCode, MAXIMUM_OBJECT_INDEX, Pack};
use rusqlite::Row;
use tilewright_jsonc::Value;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum Identity<'a> {
RenderLayer {
name: &'a str,
position: usize,
},
Plane {
id: &'a str,
document: &'a str,
},
Zone {
plane: &'a str,
id: &'a str,
document: &'a str,
},
Anchor {
plane: &'a str,
id: &'a str,
layer: i32,
at: [i64; 2],
},
Stairwell {
plane: &'a str,
id: &'a str,
document: &'a str,
},
Definition {
kind: &'a str,
id: &'a str,
document: &'a str,
},
Chunk {
plane: &'a str,
layer: i32,
cx: i64,
cy: i64,
},
Object {
uid: &'a str,
plane: &'a str,
layer: i32,
cx: i64,
cy: i64,
index: usize,
},
}
impl Pack {
fn each_row(
&self,
sql: &str,
each: &mut dyn FnMut(&Row<'_>) -> Result<(), Error>,
) -> Result<(), Error> {
let mut statement = self.connection.prepare(sql)?;
let mut rows = if statement.parameter_count() == 0 {
statement.query([])?
} else {
statement.query([maximum_document_bytes()])?
};
while let Some(row) = rows.next()? {
each(row)?;
}
Ok(())
}
pub fn visit_identities(
&self,
visit: &mut dyn FnMut(Identity<'_>) -> Result<(), Error>,
) -> Result<(), Error> {
self.verify()?;
self.visit_rows(visit)
}
fn visit_rows(
&self,
visit: &mut dyn FnMut(Identity<'_>) -> Result<(), Error>,
) -> Result<(), Error> {
let document = bounded_text("document");
let key = bounded_identifier;
self.visit_render_layers(visit)?;
self.each_row(
&format!("SELECT {} FROM planes ORDER BY id", plane_columns()),
&mut |row| {
let (id, stored) = plane_row(row)?;
visit(Identity::Plane {
id: &id,
document: stored.source(),
})
},
)?;
self.each_row(
&format!("SELECT {} FROM zones ORDER BY plane, id", zone_columns()),
&mut |row| {
let (plane, id, stored) = zone_row(row)?;
visit(Identity::Zone {
plane: &plane,
id: &id,
document: stored.source(),
})
},
)?;
self.each_row(
&format!(
"SELECT {}, {}, {document} FROM stairwells ORDER BY plane, id",
key("plane"),
key("id")
),
&mut |row| {
let (plane, id) = (
identifier(row, 0, "stairwell", ErrorCode::Stairwell)?,
identifier(row, 1, "stairwell", ErrorCode::Stairwell)?,
);
let stairwell = stored_stairwell(&stored_document(
utf8_column(row, 2, ErrorCode::Stairwell)?,
"a stairwell document",
ErrorCode::Stairwell,
)?)?;
if stairwell.id != id {
return Err(disagrees(
&format!("stairwell {plane}/{id}"),
ErrorCode::Stairwell,
));
}
visit(Identity::Stairwell {
plane: &plane,
id: &id,
document: &stairwell.canonical,
})
},
)?;
self.each_row(
&format!(
"SELECT {} FROM definitions ORDER BY type, id",
definition_columns()
),
&mut |row| {
let (kind, id, stored) = definition_row(row)?;
visit(Identity::Definition {
kind: &kind,
id: &id,
document: stored.source(),
})
},
)?;
self.visit_positions(visit)
}
fn visit_render_layers(
&self,
visit: &mut dyn FnMut(Identity<'_>) -> Result<(), Error>,
) -> Result<(), Error> {
self.profile_layers()?;
let profile = stored_document(
self.meta("profile")?,
"the stored profile",
ErrorCode::Profile,
)?;
let layers = profile
.root()
.get("render_layers")
.and_then(Value::as_array)
.ok_or_else(|| {
Error::Rejected(
ErrorCode::Profile,
"the stored profile lists no render layers".into(),
)
})?;
for (position, layer) in layers.iter().enumerate() {
let name = layer.as_str().ok_or_else(|| {
Error::Rejected(ErrorCode::Profile, "a render layer is not a string".into())
})?;
visit(Identity::RenderLayer { name, position })?;
}
Ok(())
}
fn visit_positions(
&self,
visit: &mut dyn FnMut(Identity<'_>) -> Result<(), Error>,
) -> Result<(), Error> {
let key = bounded_identifier;
self.each_row(
&format!(
"SELECT {} FROM anchors ORDER BY plane, id",
anchor_columns()
),
&mut |row| {
let anchor = anchor_row(row)?;
visit(Identity::Anchor {
plane: &anchor.plane,
id: &anchor.id,
layer: anchor.layer,
at: [anchor.x, anchor.y],
})
},
)?;
self.each_row(
&format!(
"SELECT {}, {}, {}, {} FROM chunks ORDER BY plane, layer, cx, cy",
key("plane"),
integer("layer"),
integer("cx"),
integer("cy")
),
&mut |row| {
visit(Identity::Chunk {
plane: &identifier(row, 0, "chunk", ErrorCode::Chunk)?,
layer: layer(row, 1, "chunk", ErrorCode::Chunk)?,
cx: chunk_coordinate(row, 2, ErrorCode::Chunk)?,
cy: chunk_coordinate(row, 3, ErrorCode::Chunk)?,
})
},
)?;
self.each_row(
&format!(
"SELECT {}, {}, {}, {}, {}, {} FROM object_uids ORDER BY uid",
key("uid"),
key("plane"),
integer("layer"),
integer("cx"),
integer("cy"),
integer("object")
),
&mut |row| {
let code = ErrorCode::ObjectUid;
let index: Option<i64> = row.get(5)?;
let index = index
.and_then(|index| usize::try_from(index).ok())
.filter(|index| *index <= MAXIMUM_OBJECT_INDEX)
.ok_or_else(|| {
Error::Rejected(
code,
format!(
"an object index is not an integer from 0 to {MAXIMUM_OBJECT_INDEX}"
),
)
})?;
visit(Identity::Object {
uid: &identifier(row, 0, "uid", code)?,
plane: &identifier(row, 1, "uid", code)?,
layer: layer(row, 2, "uid", code)?,
cx: chunk_coordinate(row, 3, code)?,
cy: chunk_coordinate(row, 4, code)?,
index,
})
},
)
}
}
#[cfg(test)]
mod tests {
use super::Identity;
use crate::{Error, Pack, write_pack};
use rusqlite::Connection;
use std::path::{Path, PathBuf};
fn world() -> tilewright_source::World {
let fixture = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../fixtures/basic");
tilewright_source::load(&fixture).expect("the fixture is valid")
}
fn pack(sql: &str) -> (tempfile::TempDir, PathBuf) {
let directory = tempfile::tempdir().unwrap();
let written = write_pack(&world(), directory.path()).unwrap();
let copy = directory.path().join("copy.twpack");
std::fs::copy(&written.path, ©).unwrap();
if !sql.is_empty() {
Connection::open(©)
.unwrap()
.execute_batch(&format!("{sql}; VACUUM;"))
.unwrap();
}
(directory, copy)
}
fn visit(path: &Path) -> Result<Vec<String>, Error> {
let mut listed = Vec::new();
Pack::open(path)?.visit_rows(&mut |identity| {
listed.push(match identity {
Identity::RenderLayer { name, position } => {
format!("render_layer {name} {position}")
}
Identity::Plane { id, document } => format!("plane {id} {document}"),
Identity::Zone {
plane,
id,
document,
} => format!("zone {plane}/{id} {document}"),
Identity::Anchor {
plane,
id,
layer,
at,
} => format!("anchor {plane}/{id} {layer} {at:?}"),
Identity::Stairwell {
plane,
id,
document,
} => format!("stairwell {plane}/{id} {document}"),
Identity::Definition { kind, id, document } => {
format!("definition {kind}/{id} {document}")
}
Identity::Chunk {
plane,
layer,
cx,
cy,
} => format!("chunk {plane} {layer} {cx} {cy}"),
Identity::Object {
uid,
plane,
layer,
cx,
cy,
index,
} => format!("object {uid} {plane} {layer} {cx} {cy} {index}"),
});
Ok(())
})?;
Ok(listed)
}
#[test]
fn identities_list_every_row_in_order_with_its_source_document() {
let world = world();
let mut expected = Vec::new();
for (position, name) in world.profile().render_layers.iter().enumerate() {
expected.push(format!("render_layer {name} {position}"));
}
for (id, plane) in world.planes() {
expected.push(format!("plane {id} {}", plane.canonical));
}
for (id, plane) in world.planes() {
for (zone, entry) in &plane.zones {
expected.push(format!("zone {id}/{zone} {}", entry.canonical));
}
}
for (id, plane) in world.planes() {
for (stairwell, entry) in &plane.stairwells {
expected.push(format!("stairwell {id}/{stairwell} {}", entry.canonical));
}
}
for ((kind, id), definition) in world.definitions() {
expected.push(format!("definition {kind}/{id} {}", definition.canonical));
}
for (id, plane) in world.planes() {
for (anchor, entry) in &plane.anchors {
expected.push(format!(
"anchor {id}/{anchor} {} {:?}",
entry.layer, entry.at
));
}
}
for (id, plane) in world.planes() {
for (layer, cx, cy) in plane.chunks.keys() {
expected.push(format!("chunk {id} {layer} {cx} {cy}"));
}
}
let mut objects = Vec::new();
for (id, plane) in world.planes() {
for ((layer, cx, cy), chunk) in &plane.chunks {
for (index, object) in chunk.depth_first().enumerate() {
if let Some(uid) = &object.uid {
objects.push(format!("object {uid} {id} {layer} {cx} {cy} {index}"));
}
}
}
}
objects.sort();
assert_eq!(objects.len(), 5);
expected.extend(objects);
let (_directory, path) = pack("");
let listed = visit(&path).unwrap();
assert_eq!(listed, expected);
assert_eq!(
listed
.iter()
.filter(|line| line.starts_with("chunk "))
.count(),
5
);
let mut seen = 0;
let error = Pack::open(&path)
.unwrap()
.visit_identities(&mut |_| {
seen += 1;
Err(Error::Invalid("stop".into()))
})
.unwrap_err();
assert_eq!((seen, error.to_string()), (1, "stop".to_owned()));
let mut verified = 0;
Pack::open(&path)
.unwrap()
.visit_identities(&mut |_| {
verified += 1;
Ok(())
})
.unwrap();
assert_eq!(verified, expected.len());
}
#[test]
fn identities_are_listed_only_from_packs_that_verify() {
for sql in [
"UPDATE stairwell_steps SET proto = 'stairs_up' WHERE stairwell = 'cave.chute' AND step = 0",
"INSERT INTO stairwell_steps VALUES ('overworld', 5, 1, 1, 'cave.chute', 7, 'coin')",
"UPDATE planes SET document = replace(document, '\"wilderness\"', '\"nowhere\"') WHERE id = 'overworld'",
"UPDATE zones SET plane = 'ghost' WHERE id = 'village'",
"UPDATE zones SET parent = 'village', document = replace(document, '\"name\"', '\"parent\":\"village\",\"name\"') WHERE id = 'wilderness'",
"UPDATE anchors SET plane = 'ghost' WHERE id = 'village.well'",
"UPDATE chunks SET plane = 'ghost' WHERE plane = 'vault'",
"UPDATE stairwells SET plane = 'ghost' WHERE id = 'cave.chute'",
"UPDATE object_uids SET object = 5 WHERE uid = 'village.chest'",
"DELETE FROM object_uids WHERE uid = 'vault.chest'",
] {
let (_directory, path) = pack(sql);
assert!(visit(&path).is_ok(), "{sql}: the row checks accept it");
let mut seen = 0;
let result = Pack::open(&path).unwrap().visit_identities(&mut |_| {
seen += 1;
Ok(())
});
assert!(result.is_err(), "{sql}");
assert_eq!(seen, 0, "{sql}");
}
}
const MALFORMED: &[(&str, &str)] = &[
(
"UPDATE planes SET document = replace(document, '\"vault\"', '\"other\"') WHERE id = 'vault'",
"disagrees",
),
(
"UPDATE zones SET document = '{\"id\":\"elsewhere\"}' WHERE id = 'village'",
"disagrees",
),
(
"UPDATE zones SET document = ' {}' WHERE id = 'village'",
"canonical",
),
(
"UPDATE zones SET id = 'Village' WHERE id = 'village'",
"identifier",
),
(
"UPDATE zones SET parent = 'wilderness' WHERE id = 'village'",
"disagrees",
),
(
"UPDATE zones SET parent = NULL WHERE id = 'village'",
"disagrees",
),
(
"UPDATE zones SET parent = 'x' || char(10) WHERE id = 'village'",
"identifier",
),
(
"UPDATE planes SET kind = 'shared' WHERE id = 'vault'",
"disagrees",
),
(
"UPDATE planes SET kind = 'odd', document = replace(document, '\"instanced\"', '\"odd\"') WHERE id = 'vault'",
"invalid kind",
),
(
"UPDATE planes SET chunk_size = 16 WHERE id = 'vault'",
"chunk size",
),
(
"UPDATE planes SET document = replace(document, 'plane/1', 'plane/2') WHERE id = 'vault'",
"disagrees",
),
(
"UPDATE anchors SET layer = 4294967296 WHERE id = 'village.well'",
"32-bit",
),
(
"UPDATE anchors SET x = 'one' WHERE id = 'village.well'",
"position",
),
(
"UPDATE anchors SET y = 9007199254740992 WHERE id = 'village.well'",
"position",
),
(
"UPDATE stairwells SET document = replace(document, '\"down\"', '\"sideways\"') WHERE id = 'cave.chute'",
"shape",
),
(
"UPDATE stairwells SET id = 'cave.slide' WHERE id = 'cave.chute'",
"disagrees",
),
(
"UPDATE definitions SET type = 'loot' WHERE type = 'recipe'",
"disagrees",
),
(
"UPDATE definitions SET document = zeroblob(10) WHERE id = 'coin'",
"canonical",
),
("UPDATE chunks SET layer = 'x' WHERE layer = 1", "32-bit"),
(
"UPDATE meta SET value = '{\"render_layers\":[]}' WHERE key = 'profile'",
"profile",
),
(
"UPDATE meta SET value = replace(value, '\"effect\"]', '\"effect\",\"floor\"]') WHERE key = 'profile'",
"render_layers",
),
(
"UPDATE meta SET value = '2.0.0' WHERE key = 'profile_version'",
"profile_version",
),
(
"UPDATE chunks SET cx = 281474976710656 WHERE plane = 'vault'",
"chunk coordinate",
),
(
"UPDATE chunks SET cy = -281474976710656 WHERE plane = 'vault'",
"chunk coordinate",
),
(
"UPDATE chunks SET plane = x'00' WHERE plane = 'vault'",
"identifier",
),
(
"UPDATE object_uids SET uid = 'Vault.chest' WHERE uid = 'vault.chest'",
"uid key",
),
(
"UPDATE object_uids SET plane = 'Vault' WHERE uid = 'vault.chest'",
"uid key",
),
(
"UPDATE object_uids SET layer = 2147483648 WHERE uid = 'vault.chest'",
"32-bit",
),
(
"UPDATE object_uids SET cx = 281474976710656 WHERE uid = 'vault.chest'",
"chunk coordinate",
),
(
"UPDATE object_uids SET object = -1 WHERE uid = 'vault.chest'",
"object index",
),
(
"UPDATE object_uids SET object = 16777216 WHERE uid = 'vault.chest'",
"object index",
),
];
#[test]
fn identities_reject_rows_of_the_wrong_shape() {
for (sql, problem) in MALFORMED {
let (_directory, path) = pack(sql);
let error = visit(&path).expect_err(sql).to_string();
assert!(error.contains(problem), "{sql}: {error}");
}
let (_directory, path) = pack(
"UPDATE chunks SET cx = 281474976710655, cy = -281474976710655 WHERE plane = 'vault'",
);
assert!(
visit(&path)
.unwrap()
.contains(&"chunk vault 0 281474976710655 -281474976710655".to_owned())
);
}
}