tiger_lib/imperator/data/
provinces.rs

1use std::path::{Path, PathBuf};
2use std::str::FromStr;
3
4use image::{DynamicImage, Rgb, RgbImage};
5use itertools::Itertools;
6
7use crate::block::Block;
8use crate::everything::Everything;
9#[cfg(test)]
10use crate::fileset::FileStage;
11use crate::fileset::{FileEntry, FileHandler};
12use crate::helpers::{TigerHashMap, TigerHashSet};
13use crate::item::Item;
14use crate::parse::ParserMemory;
15use crate::parse::csv::{parse_csv, read_csv};
16use crate::pdxfile::PdxFile;
17use crate::report::{ErrorKey, Severity, err, fatal, report, untidy, warn};
18use crate::token::{Loc, Token};
19
20pub type ProvId = u32;
21
22#[derive(Clone, Debug, Default)]
23pub struct ImperatorProvinces {
24    /// Colors in the provinces.png
25    colors: TigerHashSet<Rgb<u8>>,
26
27    /// Kept for adjacency coordinate validation.
28    provinces_png: Option<RgbImage>,
29
30    /// Provinces defined in definition.csv.
31    /// Imperator requires uninterrupted indices starting at 0, but we want to be able to warn
32    /// and continue if they're not, so it's a hashmap.
33    provinces: TigerHashMap<ProvId, Province>,
34
35    /// Kept and used for error reporting.
36    definition_csv: Option<FileEntry>,
37
38    adjacencies: Vec<Adjacency>,
39
40    impassable: TigerHashSet<ProvId>,
41
42    sea_or_river: TigerHashSet<ProvId>,
43
44    default_map: Option<Block>,
45
46    pending_map_files: Vec<FileEntry>,
47}
48
49fn map_filename(token: Option<&Token>) -> Option<String> {
50    token
51        .map(|value| value.as_str().trim_matches('"').to_string())
52        .filter(|value| !value.is_empty())
53}
54
55fn matches_entry(entry: &FileEntry, expected: Option<&str>) -> bool {
56    let Some(expected) = expected else { return false };
57    let expected = expected.trim();
58    if expected.is_empty() {
59        return false;
60    }
61
62    let expected_path = Path::new(expected);
63    if expected_path == entry.path() {
64        return true;
65    }
66
67    expected_path.file_name().is_some_and(|filename| filename == entry.filename())
68}
69
70fn is_map_key(key: &Token) -> bool {
71    key.lowercase_is("definitions")
72        || key.lowercase_is("provinces")
73        || key.lowercase_is("positions")
74        || key.lowercase_is("rivers")
75        || key.lowercase_is("topology")
76        || key.lowercase_is("adjacencies")
77        || key.lowercase_is("areas")
78        || key.lowercase_is("regions")
79        || key.lowercase_is("ports")
80        || key.lowercase_is("climate")
81}
82
83impl ImperatorProvinces {
84    fn expected_map_filename(&self, key: &str) -> Option<String> {
85        if let Some(block) = &self.default_map {
86            if let Some(value) = map_filename(block.get_field_value(key)) {
87                return Some(value);
88            }
89        }
90
91        match key {
92            "definitions" => Some("definition.csv".to_string()),
93            "provinces" => Some("provinces.png".to_string()),
94            "positions" => Some("positions.txt".to_string()),
95            "rivers" => Some("rivers.png".to_string()),
96            "topology" => Some("heightmap.heightmap".to_string()),
97            "adjacencies" => Some("adjacencies.csv".to_string()),
98            "areas" => Some("areas.txt".to_string()),
99            "regions" => Some("regions.txt".to_string()),
100            "ports" => Some("ports.csv".to_string()),
101            "climate" => Some("climate.txt".to_string()),
102            _ => None,
103        }
104    }
105    fn province_color(&self, provid: ProvId) -> Option<Rgb<u8>> {
106        self.provinces.get(&provid).map(|p| p.color)
107    }
108
109    fn provinces_png_pixel(&self, coords: Coords) -> Option<Rgb<u8>> {
110        let img = self.provinces_png.as_ref()?;
111
112        let x = u32::try_from(coords.x).ok()?;
113        let y = u32::try_from(coords.y).ok()?;
114
115        // Map pixels are addressed as x,y from the top-left corner.
116        if x >= img.width() || y >= img.height() {
117            return None;
118        }
119
120        Some(*img.get_pixel(x, y))
121    }
122
123    fn parse_definition(&mut self, csv: &[Token]) {
124        if let Some(province) = Province::parse(csv) {
125            if self.provinces.contains_key(&province.id) {
126                err(ErrorKey::DuplicateItem)
127                    .msg("duplicate entry for this province id")
128                    .loc(&province.comment)
129                    .push();
130            }
131            self.provinces.insert(province.id, province);
132        }
133    }
134
135    pub fn load_impassable(&mut self, block: &Block) {
136        enum Expecting<'a> {
137            Range(&'a Token),
138            List(&'a Token),
139            Nothing,
140        }
141
142        let mut expecting = Expecting::Nothing;
143        for item in block.iter_items() {
144            match expecting {
145                Expecting::Nothing => {
146                    if let Some((key, token)) = item.expect_assignment() {
147                        if key.lowercase_is("sea_zones")
148                            || key.lowercase_is("river_provinces")
149                            || key.lowercase_is("impassable_terrain")
150                            || key.lowercase_is("uninhabitable")
151                            || key.lowercase_is("wasteland")
152                            || key.lowercase_is("lakes")
153                        {
154                            if token.is("LIST") {
155                                expecting = Expecting::List(key);
156                            } else if token.is("RANGE") {
157                                expecting = Expecting::Range(key);
158                            } else {
159                                expecting = Expecting::Nothing;
160                            }
161                        } else if !is_map_key(key) {
162                            let msg = format!("unexpected key `{key}`");
163                            warn(ErrorKey::UnknownField).msg(msg).loc(key).push();
164                        }
165                    }
166                }
167                Expecting::Range(key) => {
168                    if let Some(block) = item.expect_block() {
169                        let vec: Vec<&Token> = block.iter_values().collect();
170                        if vec.len() != 2 {
171                            err(ErrorKey::Validation).msg("invalid RANGE").loc(block).push();
172                            expecting = Expecting::Nothing;
173                            continue;
174                        }
175                        let from = vec[0].as_str().parse::<ProvId>();
176                        let to = vec[1].as_str().parse::<ProvId>();
177                        if from.is_err() || to.is_err() {
178                            err(ErrorKey::Validation).msg("invalid RANGE").loc(block).push();
179                            expecting = Expecting::Nothing;
180                            continue;
181                        }
182                        for provid in from.unwrap()..=to.unwrap() {
183                            self.impassable.insert(provid);
184                            if key.is("sea_zones") || key.is("river_provinces") {
185                                self.sea_or_river.insert(provid);
186                            }
187                        }
188                    }
189                    expecting = Expecting::Nothing;
190                }
191                Expecting::List(key) => {
192                    if let Some(block) = item.expect_block() {
193                        for token in block.iter_values() {
194                            let provid = token.as_str().parse::<ProvId>();
195                            if let Ok(provid) = provid {
196                                self.impassable.insert(provid);
197                                if key.is("sea_zones") || key.is("river_provinces") {
198                                    self.sea_or_river.insert(provid);
199                                }
200                            } else {
201                                err(ErrorKey::Validation)
202                                    .msg("invalid LIST item")
203                                    .loc(token)
204                                    .push();
205                                break;
206                            }
207                        }
208                    }
209                    expecting = Expecting::Nothing;
210                }
211            }
212        }
213    }
214
215    pub fn verify_exists_implied(&self, key: &str, item: &Token, max_sev: Severity) {
216        if let Ok(provid) = key.parse::<ProvId>() {
217            if !self.provinces.contains_key(&provid) {
218                let msg = format!("province {provid} not defined in map_data/definition.csv");
219                report(ErrorKey::MissingItem, Item::Province.severity()).msg(msg).loc(item).push();
220            }
221        } else {
222            let msg = "province id should be numeric";
223            let sev = Item::Province.severity().at_most(max_sev);
224            report(ErrorKey::Validation, sev).msg(msg).loc(item).push();
225        }
226    }
227
228    pub fn exists(&self, key: &str) -> bool {
229        if let Ok(provid) = key.parse::<ProvId>() {
230            self.provinces.contains_key(&provid)
231        } else {
232            false
233        }
234    }
235
236    pub fn iter_keys(&self) -> impl Iterator<Item = &Token> {
237        self.provinces.values().map(|item| &item.key)
238    }
239
240    pub fn validate(&self, _data: &Everything) {
241        for item in &self.adjacencies {
242            item.validate(self);
243        }
244    }
245
246    fn handle_adjacencies_content(&mut self, entry: &FileEntry, content: &str) {
247        let mut seen_terminator = false;
248        for csv in parse_csv(entry, 1, content) {
249            if csv[0].is("-1") {
250                seen_terminator = true;
251            } else if seen_terminator {
252                let msg = "the line with all `-1;` should be the last line in the file";
253                warn(ErrorKey::ParseError).msg(msg).loc(&csv[0]).push();
254                break;
255            } else {
256                self.adjacencies.extend(Adjacency::parse(&csv));
257            }
258        }
259        if !seen_terminator {
260            let msg = "Imperator needs a line with all `-1;` at the end of this file";
261            err(ErrorKey::ParseError).msg(msg).loc(entry).push();
262        }
263    }
264
265    fn handle_definitions_content(&mut self, entry: &FileEntry, content: &str) {
266        self.definition_csv = Some(entry.clone());
267        for csv in parse_csv(entry, 0, content) {
268            self.parse_definition(&csv);
269        }
270    }
271
272    fn handle_provinces_image(&mut self, img: DynamicImage, entry: &FileEntry) {
273        match img {
274            DynamicImage::ImageRgb8(img) => {
275                for pixel in img.pixels().dedup() {
276                    self.colors.insert(*pixel);
277                }
278
279                // Keep the full image for validating adjacency coordinates.
280                self.provinces_png = Some(img);
281            }
282            other => {
283                let msg = format!(
284                    "`{}` has wrong color format `{:?}`, should be Rgb8",
285                    entry.path().display(),
286                    other.color()
287                );
288                err(ErrorKey::ImageFormat).msg(msg).loc(entry).push();
289            }
290        }
291    }
292
293    fn process_map_entry(&mut self, entry: &FileEntry) {
294        let adjacencies = self.expected_map_filename("adjacencies");
295        if matches_entry(entry, adjacencies.as_deref()) {
296            let content = match read_csv(entry.fullpath()) {
297                Ok(content) => content,
298                Err(e) => {
299                    err(ErrorKey::ReadError)
300                        .msg(format!("could not read file: {e:#}"))
301                        .loc(entry)
302                        .push();
303                    return;
304                }
305            };
306            self.handle_adjacencies_content(entry, &content);
307            return;
308        }
309
310        let definitions = self.expected_map_filename("definitions");
311        if matches_entry(entry, definitions.as_deref()) {
312            let content = match read_csv(entry.fullpath()) {
313                Ok(content) => content,
314                Err(e) => {
315                    let msg = format!("could not read `{}`: {:#}", entry.path().display(), e);
316                    err(ErrorKey::ReadError).msg(msg).loc(entry).push();
317                    return;
318                }
319            };
320            self.handle_definitions_content(entry, &content);
321            return;
322        }
323
324        let provinces = self.expected_map_filename("provinces");
325        if matches_entry(entry, provinces.as_deref()) {
326            let img = match image::open(entry.fullpath()) {
327                Ok(img) => img,
328                Err(e) => {
329                    let msg = format!("could not read `{}`: {e:#}", entry.path().display());
330                    err(ErrorKey::ReadError).msg(msg).loc(entry).push();
331                    return;
332                }
333            };
334            self.handle_provinces_image(img, entry);
335        }
336    }
337
338    fn process_pending_map_entries(&mut self) {
339        let pending = std::mem::take(&mut self.pending_map_files);
340        for entry in pending {
341            self.process_map_entry(&entry);
342        }
343    }
344}
345
346#[derive(Debug)]
347pub enum FileContent {
348    DefaultMap(Block),
349    Deferred,
350}
351
352impl FileHandler<FileContent> for ImperatorProvinces {
353    fn subpath(&self) -> PathBuf {
354        PathBuf::from("map_data")
355    }
356
357    fn load_file(&self, entry: &FileEntry, parser: &ParserMemory) -> Option<FileContent> {
358        if entry.path().components().count() == 2 {
359            if &*entry.filename().to_string_lossy() == "default.map" {
360                return PdxFile::read_optional_bom(entry, parser).map(FileContent::DefaultMap);
361            }
362            return Some(FileContent::Deferred);
363        }
364        None
365    }
366
367    fn handle_file(&mut self, entry: &FileEntry, content: FileContent) {
368        match content {
369            FileContent::DefaultMap(block) => {
370                self.default_map = Some(block.clone());
371                self.load_impassable(&block);
372            }
373            FileContent::Deferred => {
374                if self.default_map.is_some() {
375                    self.process_map_entry(entry);
376                } else {
377                    self.pending_map_files.push(entry.clone());
378                }
379            }
380        }
381    }
382
383    fn finalize(&mut self) {
384        self.process_pending_map_entries();
385
386        if self.definition_csv.is_none() {
387            // Shouldn't happen, it should come from vanilla if not from the mod
388            eprintln!("map_data/definition.csv is missing?!?");
389            return;
390        }
391        let definition_csv = self.definition_csv.as_ref().unwrap();
392
393        let mut seen_colors = TigerHashMap::default();
394        #[allow(clippy::cast_possible_truncation)]
395        for i in 1..self.provinces.len() as u32 {
396            if let Some(province) = self.provinces.get(&i) {
397                if let Some(k) = seen_colors.get(&province.color) {
398                    let msg = format!("color was already used for id {k}");
399                    warn(ErrorKey::Colors).msg(msg).loc(&province.comment).push();
400                } else {
401                    seen_colors.insert(province.color, i);
402                }
403            } else {
404                let msg = format!("province ids must be sequential, but {i} is missing");
405                err(ErrorKey::Validation).msg(msg).loc(definition_csv).push();
406                return;
407            }
408        }
409        for color in &self.colors {
410            if !seen_colors.contains_key(color) {
411                let Rgb(rgb) = color;
412                let msg = format!(
413                    "definitions.csv lacks entry for color ({}, {}, {})",
414                    rgb[0], rgb[1], rgb[2]
415                );
416                untidy(ErrorKey::Colors).msg(msg).loc(definition_csv).push();
417            }
418        }
419    }
420}
421
422#[derive(Copy, Clone, Debug, Default)]
423pub struct Coords {
424    x: i32,
425    y: i32,
426}
427
428impl Coords {
429    fn is_sentinel(self) -> bool {
430        self.x == -1 && self.y == -1
431    }
432}
433
434#[allow(dead_code)] // TODO
435#[derive(Clone, Debug)]
436pub struct Adjacency {
437    line: Loc,
438    from: ProvId,
439    to: ProvId,
440    /// Adjacency kind, should be `sea` or `river_large`.
441    kind: Token,
442    through: ProvId,
443    /// start and stop are map coordinates (should be within provinces.png bounds) and should have the right color on provinces.png
444    /// They can be -1 -1 though.
445    start: Coords,
446    stop: Coords,
447    comment: Token,
448}
449
450fn verify<T: FromStr>(v: &Token, msg: &str) -> Option<T> {
451    let r = v.as_str().parse().ok();
452    if r.is_none() {
453        err(ErrorKey::ParseError).msg(msg).loc(v).push();
454    }
455    r
456}
457
458impl Adjacency {
459    pub fn parse(csv: &[Token]) -> Option<Self> {
460        if csv.is_empty() {
461            return None;
462        }
463
464        let line = csv[0].loc;
465
466        if csv.len() != 9 {
467            let msg = "wrong number of fields for this line, expected 9";
468            err(ErrorKey::ParseError).msg(msg).loc(&csv[0]).push();
469            return None;
470        }
471
472        let from = verify(&csv[0], "expected province id");
473        let to = verify(&csv[1], "expected province id");
474        let through = verify(&csv[3], "expected province id");
475        let start_x = verify(&csv[4], "expected x coordinate");
476        let start_y = verify(&csv[5], "expected y coordinate");
477        let stop_x = verify(&csv[6], "expected x coordinate");
478        let stop_y = verify(&csv[7], "expected y coordinate");
479
480        Some(Adjacency {
481            line,
482            from: from?,
483            to: to?,
484            kind: csv[2].clone(),
485            through: through?,
486            start: Coords { x: start_x?, y: start_y? },
487            stop: Coords { x: stop_x?, y: stop_y? },
488            comment: csv[8].clone(),
489        })
490    }
491
492    fn validate(&self, provinces: &ImperatorProvinces) {
493        for prov in &[self.from, self.to, self.through] {
494            if !provinces.provinces.contains_key(prov) {
495                let msg = format!("province id {prov} not defined in definitions.csv");
496                fatal(ErrorKey::Crash).msg(msg).loc(self.line).push();
497            }
498        }
499
500        if !self.kind.lowercase_is("sea") && !self.kind.lowercase_is("river_large") {
501            let msg = format!(
502                "adjacency type `{}` is invalid; expected `sea` or `river_large`",
503                self.kind.as_str()
504            );
505            err(ErrorKey::Validation).msg(msg).loc(&self.kind).push();
506        }
507
508        if self.start.is_sentinel() && self.stop.is_sentinel() {
509            return;
510        }
511
512        let Some(img) = provinces.provinces_png.as_ref() else {
513            // Can't validate coordinates without provinces.png.
514            return;
515        };
516
517        let (w, h) = (img.width(), img.height());
518        for (label, coords) in [("start", &self.start), ("stop", &self.stop)] {
519            if coords.is_sentinel() {
520                continue;
521            }
522
523            let x = u32::try_from(coords.x);
524            let y = u32::try_from(coords.y);
525            if x.is_err() || y.is_err() {
526                let msg = format!(
527                    "{label} coordinate ({}, {}) is out of bounds (image size {}x{})",
528                    coords.x, coords.y, w, h
529                );
530                err(ErrorKey::Validation).msg(msg).loc(&self.comment).push();
531                continue;
532            }
533            let (x, y) = (x.unwrap(), y.unwrap());
534            if x >= w || y >= h {
535                let msg = format!(
536                    "{label} coordinate ({}, {}) is out of bounds (image size {}x{})",
537                    coords.x, coords.y, w, h
538                );
539                err(ErrorKey::Validation).msg(msg).loc(&self.comment).push();
540            }
541        }
542
543        if !self.start.is_sentinel() {
544            let Some(expected_start) = provinces.province_color(self.from) else {
545                return;
546            };
547            if let Some(actual) = provinces.provinces_png_pixel(self.start) {
548                if actual != expected_start {
549                    let Rgb([er, eg, eb]) = expected_start;
550                    let Rgb([ar, ag, ab]) = actual;
551                    let msg = format!(
552                        "start coordinate is in the wrong province color: expected ({er}, {eg}, {eb}), got ({ar}, {ag}, {ab})"
553                    );
554                    err(ErrorKey::Validation).msg(msg).loc(&self.comment).push();
555                }
556            }
557        }
558
559        if !self.stop.is_sentinel() {
560            let Some(expected_stop) = provinces.province_color(self.to) else {
561                return;
562            };
563            if let Some(actual) = provinces.provinces_png_pixel(self.stop) {
564                if actual != expected_stop {
565                    let Rgb([er, eg, eb]) = expected_stop;
566                    let Rgb([ar, ag, ab]) = actual;
567                    let msg = format!(
568                        "stop coordinate is in the wrong province color: expected ({er}, {eg}, {eb}), got ({ar}, {ag}, {ab})"
569                    );
570                    err(ErrorKey::Validation).msg(msg).loc(&self.comment).push();
571                }
572            }
573        }
574    }
575}
576
577#[derive(Clone, Debug)]
578pub struct Province {
579    key: Token,
580    id: ProvId,
581    color: Rgb<u8>,
582    comment: Token,
583}
584
585impl Province {
586    fn parse(csv: &[Token]) -> Option<Self> {
587        if csv.is_empty() {
588            return None;
589        }
590
591        if csv.len() < 5 {
592            let msg = "too few fields for this line, expected 5";
593            err(ErrorKey::ParseError).msg(msg).loc(&csv[0]).push();
594            return None;
595        }
596
597        let id = verify(&csv[0], "expected province id")?;
598        let r = verify(&csv[1], "expected red value")?;
599        let g = verify(&csv[2], "expected green value")?;
600        let b = verify(&csv[3], "expected blue value")?;
601        let color = Rgb::from([r, g, b]);
602        Some(Province { key: csv[0].clone(), id, color, comment: csv[4].clone() })
603    }
604}
605
606#[cfg(test)]
607mod tests {
608    use super::*;
609
610    use std::path::PathBuf;
611    use std::sync::{LazyLock, Mutex};
612
613    use crate::fileset::FileKind;
614    use crate::report::take_reports;
615
616    static TEST_MUTEX: LazyLock<Mutex<()>> = LazyLock::new(|| Mutex::new(()));
617
618    fn with_test_lock<T>(f: impl FnOnce() -> T) -> T {
619        let _guard = TEST_MUTEX.lock().unwrap();
620        let _ = take_reports();
621        f()
622    }
623
624    fn loc(line: u32, column: u32) -> Loc {
625        let mut loc = Loc::for_file(
626            PathBuf::from("map_data/adjacencies.csv"),
627            FileStage::NoStage,
628            FileKind::Mod,
629            PathBuf::from("C:/test/map_data/adjacencies.csv"),
630        );
631        loc.line = line;
632        loc.column = column;
633        loc
634    }
635
636    fn tok(s: &str, line: u32, column: u32) -> Token {
637        Token::new(s, loc(line, column))
638    }
639
640    fn base_provinces(img: RgbImage, from_color: Rgb<u8>, to_color: Rgb<u8>) -> ImperatorProvinces {
641        let _ = take_reports();
642
643        let mut provinces = ImperatorProvinces::default();
644        provinces.provinces_png = Some(img);
645        provinces.provinces.insert(
646            1,
647            Province { key: tok("1", 1, 1), id: 1, color: from_color, comment: tok("c", 1, 1) },
648        );
649        provinces.provinces.insert(
650            2,
651            Province { key: tok("2", 1, 1), id: 2, color: to_color, comment: tok("c", 1, 1) },
652        );
653        provinces
654    }
655
656    fn adjacency(start: Coords, stop: Coords) -> Adjacency {
657        Adjacency {
658            line: loc(1, 1),
659            from: 1,
660            to: 2,
661            kind: tok("sea", 1, 5),
662            through: 1,
663            start,
664            stop,
665            comment: tok("comment", 1, 10),
666        }
667    }
668
669    fn adjacency_with_kind(kind: &str, start: Coords, stop: Coords) -> Adjacency {
670        Adjacency { kind: tok(kind, 1, 5), ..adjacency(start, stop) }
671    }
672
673    fn take_msgs() -> Vec<String> {
674        take_reports().into_iter().map(|(meta, _)| meta.msg).collect()
675    }
676
677    #[test]
678    fn adjacency_start_out_of_bounds_errors() {
679        with_test_lock(|| {
680            let img = RgbImage::from_pixel(2, 2, Rgb([1, 2, 3]));
681            let provinces = base_provinces(img, Rgb([1, 2, 3]), Rgb([9, 9, 9]));
682
683            let adj = adjacency(Coords { x: 5, y: 0 }, Coords { x: -1, y: -1 });
684            adj.validate(&provinces);
685
686            let msgs = take_msgs();
687            assert!(
688                msgs.iter().any(|m| m.contains("start coordinate (5, 0) is out of bounds")),
689                "reports were: {msgs:?}"
690            );
691        });
692    }
693
694    #[test]
695    fn adjacency_start_wrong_color_errors() {
696        with_test_lock(|| {
697            let mut img = RgbImage::from_pixel(2, 2, Rgb([0, 0, 0]));
698            img.put_pixel(0, 0, Rgb([9, 9, 9]));
699            let provinces = base_provinces(img, Rgb([1, 2, 3]), Rgb([7, 8, 9]));
700
701            let adj = adjacency(Coords { x: 0, y: 0 }, Coords { x: -1, y: -1 });
702            adj.validate(&provinces);
703
704            let msgs = take_msgs();
705            assert!(
706                msgs.iter().any(|m| m.contains("start coordinate is in the wrong province color")),
707                "reports were: {msgs:?}"
708            );
709        });
710    }
711
712    #[test]
713    fn adjacency_stop_wrong_color_errors_when_start_sentinel() {
714        with_test_lock(|| {
715            let mut img = RgbImage::from_pixel(2, 2, Rgb([0, 0, 0]));
716            img.put_pixel(1, 1, Rgb([9, 9, 9]));
717            let provinces = base_provinces(img, Rgb([1, 2, 3]), Rgb([7, 8, 9]));
718
719            let adj = adjacency(Coords { x: -1, y: -1 }, Coords { x: 1, y: 1 });
720            adj.validate(&provinces);
721
722            let msgs = take_msgs();
723            assert!(
724                msgs.iter().any(|m| m.contains("stop coordinate is in the wrong province color")),
725                "reports were: {msgs:?}"
726            );
727        });
728    }
729
730    #[test]
731    fn adjacency_one_endpoint_sentinel_can_still_pass() {
732        with_test_lock(|| {
733            let mut img = RgbImage::from_pixel(2, 2, Rgb([0, 0, 0]));
734            img.put_pixel(1, 0, Rgb([7, 8, 9]));
735            let provinces = base_provinces(img, Rgb([1, 2, 3]), Rgb([7, 8, 9]));
736
737            let adj = adjacency(Coords { x: -1, y: -1 }, Coords { x: 1, y: 0 });
738            adj.validate(&provinces);
739
740            let msgs = take_msgs();
741            assert!(msgs.is_empty(), "reports were: {msgs:?}");
742        });
743    }
744
745    #[test]
746    fn adjacency_kind_invalid_errors_even_if_coords_sentinel() {
747        with_test_lock(|| {
748            let img = RgbImage::from_pixel(2, 2, Rgb([1, 2, 3]));
749            let provinces = base_provinces(img, Rgb([1, 2, 3]), Rgb([9, 9, 9]));
750
751            let adj = adjacency_with_kind("land", Coords { x: -1, y: -1 }, Coords { x: -1, y: -1 });
752            adj.validate(&provinces);
753
754            let msgs = take_msgs();
755            assert!(
756                msgs.iter().any(|m| m.contains("adjacency type `land` is invalid")),
757                "reports were: {msgs:?}"
758            );
759        });
760    }
761
762    #[test]
763    fn adjacency_kind_sea_and_river_large_are_allowed() {
764        with_test_lock(|| {
765            let mut img = RgbImage::from_pixel(2, 2, Rgb([0, 0, 0]));
766            img.put_pixel(0, 0, Rgb([1, 2, 3]));
767            img.put_pixel(1, 0, Rgb([7, 8, 9]));
768            let provinces = base_provinces(img, Rgb([1, 2, 3]), Rgb([7, 8, 9]));
769
770            for kind in ["sea", "river_large"] {
771                let adj = adjacency_with_kind(kind, Coords { x: 0, y: 0 }, Coords { x: 1, y: 0 });
772                adj.validate(&provinces);
773
774                let msgs = take_msgs();
775                assert!(msgs.is_empty(), "kind {kind} reports were: {msgs:?}");
776            }
777        });
778    }
779}