Skip to main content

flatland_client_ui/
map_presentation.rs

1//! Map presentation catalogs (`assets/world/terrain-kinds.yaml`, `map-glyphs.yaml`, item templates).
2
3use crate::color::{parse_color, RgbColor};
4
5use std::collections::HashMap;
6use std::path::PathBuf;
7use std::sync::{OnceLock, RwLock};
8
9use flatland_protocol::{NpcView, ResourceNodeState, ResourceNodeView, TerrainKindView};
10use serde::Deserialize;
11
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct MapPresentation {
14    pub glyph: String,
15    pub color: RgbColor,
16}
17
18impl MapPresentation {
19    fn new(glyph: impl Into<String>, color: RgbColor) -> Self {
20        Self {
21            glyph: glyph.into(),
22            color,
23        }
24    }
25}
26
27#[derive(Debug, Default, Deserialize)]
28struct DefaultsYaml {
29    #[serde(default)]
30    resource: Option<GlyphColorYaml>,
31    #[serde(default)]
32    resource_harvesting: Option<GlyphColorYaml>,
33    #[serde(default)]
34    resource_cooldown: Option<GlyphColorYaml>,
35    #[serde(default)]
36    npc_wildlife: Option<GlyphColorYaml>,
37    #[serde(default)]
38    npc_friendly: Option<GlyphColorYaml>,
39    #[serde(default)]
40    player: Option<GlyphColorYaml>,
41    #[serde(default)]
42    corpse: Option<GlyphColorYaml>,
43    #[serde(default)]
44    loot: Option<GlyphColorYaml>,
45    #[serde(default)]
46    chest: Option<GlyphColorYaml>,
47    #[serde(default)]
48    chest_locked: Option<GlyphColorYaml>,
49    #[serde(default)]
50    door_closed: Option<GlyphColorYaml>,
51    #[serde(default)]
52    door_open: Option<GlyphColorYaml>,
53    #[serde(default)]
54    wall: Option<GlyphColorYaml>,
55    #[serde(default)]
56    well_center: Option<GlyphColorYaml>,
57    #[serde(default)]
58    quest_board: Option<GlyphColorYaml>,
59}
60
61#[derive(Debug, Deserialize)]
62struct GlyphColorYaml {
63    glyph: String,
64    color: String,
65}
66
67#[derive(Debug, Deserialize)]
68struct TerrainKindsFile {
69    terrain_kinds: HashMap<String, GlyphColorYaml>,
70}
71
72#[derive(Debug, Deserialize)]
73struct ItemGlyphsFile {
74    items: Vec<ItemGlyphEntry>,
75}
76
77#[derive(Debug, Deserialize)]
78struct ItemGlyphEntry {
79    template_id: String,
80    #[serde(default)]
81    glyph: Option<String>,
82    #[serde(default)]
83    color: Option<String>,
84}
85
86#[derive(Debug, Deserialize)]
87struct MapGlyphsFile {
88    #[serde(default)]
89    defaults: DefaultsYaml,
90    #[serde(default)]
91    npcs: HashMap<String, GlyphColorYaml>,
92    #[serde(default)]
93    entities: HashMap<String, GlyphColorYaml>,
94}
95
96#[derive(Debug)]
97struct Catalog {
98    terrain: HashMap<String, MapPresentation>,
99    defaults: DefaultsBlock,
100    items: HashMap<String, MapPresentation>,
101    npcs: HashMap<String, MapPresentation>,
102    entities: HashMap<String, MapPresentation>,
103}
104
105#[derive(Debug)]
106struct DefaultsBlock {
107    resource: MapPresentation,
108    resource_harvesting: MapPresentation,
109    resource_cooldown: MapPresentation,
110    npc_wildlife: MapPresentation,
111    npc_friendly: MapPresentation,
112    player: MapPresentation,
113    corpse: MapPresentation,
114    loot: MapPresentation,
115    chest: MapPresentation,
116    chest_locked: MapPresentation,
117    door_closed: MapPresentation,
118    door_open: MapPresentation,
119    wall: MapPresentation,
120    well_center: MapPresentation,
121    quest_board: MapPresentation,
122}
123
124fn find_assets_path(relative: &str) -> PathBuf {
125    let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
126    for ancestor in manifest.ancestors() {
127        let candidate = ancestor.join(relative);
128        if candidate.is_file() {
129            return candidate;
130        }
131    }
132    PathBuf::from(relative)
133}
134
135fn find_assets_dir(relative: &str) -> PathBuf {
136    let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
137    for ancestor in manifest.ancestors() {
138        let candidate = ancestor.join(relative);
139        if candidate.is_dir() {
140            return candidate;
141        }
142    }
143    PathBuf::from(relative)
144}
145
146fn parse_entry(def: &GlyphColorYaml) -> Option<MapPresentation> {
147    if def.glyph.is_empty() {
148        return None;
149    }
150    let color = parse_color(&def.color)?;
151    Some(MapPresentation::new(def.glyph.clone(), color))
152}
153
154fn parse_map(defs: HashMap<String, GlyphColorYaml>) -> HashMap<String, MapPresentation> {
155    defs.into_iter()
156        .filter_map(|(k, v)| parse_entry(&v).map(|p| (k, p)))
157        .collect()
158}
159
160fn builtin_defaults() -> DefaultsBlock {
161    DefaultsBlock {
162        resource: MapPresentation::new("?", RgbColor::DARK_GRAY),
163        resource_harvesting: MapPresentation::new("%", RgbColor::YELLOW),
164        resource_cooldown: MapPresentation::new("·", RgbColor::DARK_GRAY),
165        npc_wildlife: MapPresentation::new("N", RgbColor::YELLOW),
166        npc_friendly: MapPresentation::new("N", RgbColor::CYAN),
167        player: MapPresentation::new("@", RgbColor::GREEN),
168        corpse: MapPresentation::new("x", RgbColor::DARK_GRAY),
169        loot: MapPresentation::new("*", RgbColor::YELLOW),
170        chest: MapPresentation::new("■", RgbColor::rgb(139, 90, 43)),
171        chest_locked: MapPresentation::new("▣", RgbColor::rgb(160, 82, 45)),
172        door_closed: MapPresentation::new("D", RgbColor::RED),
173        door_open: MapPresentation::new("d", RgbColor::LIGHT_RED),
174        wall: MapPresentation::new("+", RgbColor::MAGENTA),
175        well_center: MapPresentation::new("O", RgbColor::CYAN),
176        quest_board: MapPresentation::new("!", RgbColor::rgb(0xe8, 0xc5, 0x47)),
177    }
178}
179
180fn merge_default(
181    yaml: Option<GlyphColorYaml>,
182    fallback: &MapPresentation,
183) -> MapPresentation {
184    yaml.and_then(|d| parse_entry(&d))
185        .unwrap_or_else(|| fallback.clone())
186}
187
188fn builtin_terrain(kind: TerrainKindView) -> MapPresentation {
189    match kind {
190        TerrainKindView::Grass => MapPresentation::new(".", RgbColor::rgb(0x5a, 0x73, 0x56)),
191        TerrainKindView::Hill => MapPresentation::new("^", RgbColor::rgb(0x9a, 0x92, 0x68)),
192        TerrainKindView::Trail => MapPresentation::new(":", RgbColor::rgb(0xc4, 0xb8, 0x90)),
193        TerrainKindView::Rock => MapPresentation::new("#", RgbColor::rgb(0x5a, 0x50, 0x48)),
194        TerrainKindView::Bog => MapPresentation::new(",", RgbColor::MAGENTA),
195        TerrainKindView::ShallowWater => MapPresentation::new("~", RgbColor::CYAN),
196        TerrainKindView::DeepWater => MapPresentation::new("~", RgbColor::BLUE),
197    }
198}
199
200fn terrain_key(kind: TerrainKindView) -> &'static str {
201    match kind {
202        TerrainKindView::Grass => "grass",
203        TerrainKindView::Hill => "hill",
204        TerrainKindView::Trail => "trail",
205        TerrainKindView::Rock => "rock",
206        TerrainKindView::Bog => "bog",
207        TerrainKindView::ShallowWater => "shallow_water",
208        TerrainKindView::DeepWater => "deep_water",
209    }
210}
211
212/// Ground elevation → hue ladder (low cool green → mid olive → high warm tan → snow).
213fn elevation_palette(elevation: f32) -> RgbColor {
214    let bucket = elevation.round().clamp(-3.0, 8.0) as i32;
215    match bucket {
216        b if b <= -1 => RgbColor::rgb(0x3d, 0x58, 0x6a), // below grade — cool slate
217        0 => RgbColor::rgb(0x4a, 0x6b, 0x42),            // lowland green
218        1 => RgbColor::rgb(0x62, 0x7a, 0x48),            // foothill
219        2 => RgbColor::rgb(0x7a, 0x86, 0x4c),            // slope olive
220        3 => RgbColor::rgb(0x96, 0x8a, 0x58),            // upper slope tan-green
221        4 => RgbColor::rgb(0xae, 0x8c, 0x62),            // ridge / plateau
222        5 => RgbColor::rgb(0xc8, 0xa4, 0x72),            // high scree
223        6 => RgbColor::rgb(0xde, 0xc0, 0x90),            // near summit
224        _ => RgbColor::rgb(0xec, 0xea, 0xf4),            // snow cap
225    }
226}
227
228fn darken_rgb(color: RgbColor, factor: f32) -> RgbColor {
229    RgbColor::rgb(
230        (color.r as f32 * factor) as u8,
231        (color.g as f32 * factor) as u8,
232        (color.b as f32 * factor) as u8,
233    )
234}
235
236fn lighten_rgb(color: RgbColor, factor: f32) -> RgbColor {
237    RgbColor::rgb(
238        ((color.r as f32 * factor).min(255.0)) as u8,
239        ((color.g as f32 * factor).min(255.0)) as u8,
240        ((color.b as f32 * factor).min(255.0)) as u8,
241    )
242}
243
244fn load_item_presentations() -> HashMap<String, MapPresentation> {
245    let path = find_assets_dir("assets/items");
246    let Ok(entries) = std::fs::read_dir(&path) else {
247        return HashMap::new();
248    };
249    let fallback = RgbColor::rgb(0x8a, 0x8a, 0x8a);
250    let mut out = HashMap::new();
251    let mut files: Vec<PathBuf> = entries
252        .filter_map(|e| e.ok())
253        .map(|e| e.path())
254        .filter(|p| {
255            p.is_file()
256                && p.extension()
257                    .is_some_and(|ext| ext == "yaml" || ext == "yml")
258        })
259        .collect();
260    files.sort();
261    for file in files {
262        let Ok(raw) = std::fs::read_to_string(&file) else {
263            continue;
264        };
265        let Ok(root) = serde_yaml::from_str::<ItemGlyphsFile>(&raw) else {
266            continue;
267        };
268        for item in root.items {
269            let Some(glyph) = item.glyph else {
270                continue;
271            };
272            let color = item
273                .color
274                .as_deref()
275                .and_then(parse_color)
276                .unwrap_or(fallback);
277            out.insert(item.template_id, MapPresentation::new(glyph, color));
278        }
279    }
280    out
281}
282
283fn load_catalog() -> Catalog {
284    let mut terrain = HashMap::new();
285    if let Ok(raw) = std::fs::read_to_string(find_assets_path("assets/world/terrain-kinds.yaml")) {
286        if let Ok(file) = serde_yaml::from_str::<TerrainKindsFile>(&raw) {
287            terrain = parse_map(file.terrain_kinds);
288        }
289    }
290
291    let mut defaults = builtin_defaults();
292    let items = load_item_presentations();
293    let mut npcs = HashMap::new();
294    let mut entities = HashMap::new();
295
296    if let Ok(raw) = std::fs::read_to_string(find_assets_path("assets/world/map-glyphs.yaml")) {
297        if let Ok(file) = serde_yaml::from_str::<MapGlyphsFile>(&raw) {
298            let base = builtin_defaults();
299            defaults = DefaultsBlock {
300                resource: merge_default(file.defaults.resource, &base.resource),
301                resource_harvesting: merge_default(
302                    file.defaults.resource_harvesting,
303                    &base.resource_harvesting,
304                ),
305                resource_cooldown: merge_default(
306                    file.defaults.resource_cooldown,
307                    &base.resource_cooldown,
308                ),
309                npc_wildlife: merge_default(file.defaults.npc_wildlife, &base.npc_wildlife),
310                npc_friendly: merge_default(file.defaults.npc_friendly, &base.npc_friendly),
311                player: merge_default(file.defaults.player, &base.player),
312                corpse: merge_default(file.defaults.corpse, &base.corpse),
313                loot: merge_default(file.defaults.loot, &base.loot),
314                chest: merge_default(file.defaults.chest, &base.chest),
315                chest_locked: merge_default(file.defaults.chest_locked, &base.chest_locked),
316                door_closed: merge_default(file.defaults.door_closed, &base.door_closed),
317                door_open: merge_default(file.defaults.door_open, &base.door_open),
318                wall: merge_default(file.defaults.wall, &base.wall),
319                well_center: merge_default(file.defaults.well_center, &base.well_center),
320                quest_board: merge_default(file.defaults.quest_board, &base.quest_board),
321            };
322            npcs = parse_map(file.npcs);
323            entities = parse_map(file.entities);
324        }
325    }
326
327    Catalog {
328        terrain,
329        defaults,
330        items,
331        npcs,
332        entities,
333    }
334}
335
336fn catalog_cell() -> &'static RwLock<Catalog> {
337    static CELL: OnceLock<RwLock<Catalog>> = OnceLock::new();
338    CELL.get_or_init(|| RwLock::new(load_catalog()))
339}
340
341fn catalog() -> std::sync::RwLockReadGuard<'static, Catalog> {
342    catalog_cell().read().expect("map presentation catalog lock")
343}
344
345/// Reload `terrain-kinds.yaml`, `map-glyphs.yaml`, and item glyphs from disk.
346pub fn reload_map_presentation_catalog() {
347    *catalog_cell()
348        .write()
349        .expect("map presentation catalog lock") = load_catalog();
350}
351
352/// Reload presentation YAML when the sim reports a newer content revision (after publish).
353pub fn maybe_reload_for_content_rev(content_rev: u64) {
354    static LAST_REV: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
355    let prev = LAST_REV.load(std::sync::atomic::Ordering::Relaxed);
356    if content_rev > 0 && content_rev != prev {
357        LAST_REV.store(content_rev, std::sync::atomic::Ordering::Relaxed);
358        reload_map_presentation_catalog();
359    }
360}
361
362fn npc_glyph_lookup(c: &Catalog, npc: &NpcView) -> Option<MapPresentation> {
363    let id_key = npc.id.to_ascii_lowercase();
364    if let Some(p) = c.npcs.get(&id_key) {
365        return Some(p.clone());
366    }
367    if let Some(prefix) = id_key.split('_').next() {
368        if let Some(p) = c.npcs.get(prefix) {
369            return Some(p.clone());
370        }
371    }
372    let label_key = npc.label.to_ascii_lowercase();
373    c.npcs.get(&label_key).cloned()
374}
375
376pub fn terrain_for(kind: TerrainKindView) -> MapPresentation {
377    catalog()
378        .terrain
379        .get(terrain_key(kind))
380        .cloned()
381        .unwrap_or_else(|| builtin_terrain(kind))
382}
383
384/// Terrain glyph + color from zone kind and ground elevation (topographic read).
385pub fn terrain_for_elevation(kind: TerrainKindView, elevation: f32) -> MapPresentation {
386    match kind {
387        TerrainKindView::ShallowWater | TerrainKindView::DeepWater | TerrainKindView::Bog => {
388            return terrain_for(kind);
389        }
390        TerrainKindView::Trail => {
391            let color = lighten_rgb(elevation_palette(elevation), 1.12);
392            return MapPresentation::new(":", color);
393        }
394        TerrainKindView::Rock => {
395            let color = darken_rgb(elevation_palette(elevation), 0.62);
396            let glyph = if elevation >= 3.5 { "▓" } else { "#" };
397            return MapPresentation::new(glyph, color);
398        }
399        _ => {}
400    }
401
402    let base = terrain_for(kind);
403    let color = elevation_palette(elevation);
404    if elevation.abs() < 0.25 {
405        return MapPresentation::new(base.glyph, color);
406    }
407
408    let bucket = elevation.round().clamp(-9.0, 9.0) as i32;
409    let ch = match bucket {
410        b if b >= 6 => '▲',
411        b if b >= 4 => '▲',
412        b if b >= 2 => '^',
413        b if b >= 1 => '▴',
414        b if b <= -1 => '▾',
415        _ => base
416            .glyph
417            .chars()
418            .next()
419            .unwrap_or('.'),
420    };
421    MapPresentation::new(ch.to_string(), color)
422}
423
424/// Terrain presentation for a zone cell, applying optional per-zone glyph/color overrides from YAML.
425pub fn terrain_for_zone(
426    kind: TerrainKindView,
427    elevation: f32,
428    glyph_override: Option<&str>,
429    color_override: Option<&str>,
430) -> MapPresentation {
431    let mut pres = terrain_for_elevation(kind, elevation);
432    if let Some(g) = glyph_override.map(str::trim).filter(|s| !s.is_empty()) {
433        let ch = g.chars().next().unwrap_or('.');
434        pres.glyph = ch.to_string();
435    }
436    if let Some(raw) = color_override.map(str::trim).filter(|s| !s.is_empty()) {
437        if let Some(color) = parse_color(raw) {
438            pres.color = color;
439        }
440    }
441    pres
442}
443
444pub fn resource_for(node: &ResourceNodeView) -> MapPresentation {
445    let c = catalog();
446    match node.state {
447        ResourceNodeState::Harvesting => c.defaults.resource_harvesting.clone(),
448        ResourceNodeState::Cooldown => c.defaults.resource_cooldown.clone(),
449        ResourceNodeState::Available => c
450            .items
451            .get(&node.item_template)
452            .cloned()
453            .unwrap_or_else(|| c.defaults.resource.clone()),
454    }
455}
456
457pub fn npc_for(npc: &NpcView) -> MapPresentation {
458    let c = catalog();
459    if let Some(p) = npc_glyph_lookup(&c, npc) {
460        return p;
461    }
462    if npc.entity_id.is_some() {
463        return c.defaults.npc_wildlife.clone();
464    }
465    c.defaults.npc_friendly.clone()
466}
467
468pub fn player_presentation() -> MapPresentation {
469    catalog().defaults.player.clone()
470}
471
472pub fn corpse_presentation() -> MapPresentation {
473    catalog().defaults.corpse.clone()
474}
475
476pub fn loot_presentation() -> MapPresentation {
477    catalog().defaults.loot.clone()
478}
479
480pub fn chest_presentation(locked: bool) -> MapPresentation {
481    let c = catalog();
482    if locked {
483        c.defaults.chest_locked.clone()
484    } else {
485        c.defaults.chest.clone()
486    }
487}
488
489pub fn door_presentation(open: bool) -> MapPresentation {
490    let c = catalog();
491    if open {
492        c.defaults.door_open.clone()
493    } else {
494        c.defaults.door_closed.clone()
495    }
496}
497
498pub fn wall_presentation() -> MapPresentation {
499    catalog().defaults.wall.clone()
500}
501
502pub fn well_center_presentation() -> MapPresentation {
503    catalog().defaults.well_center.clone()
504}
505
506pub fn quest_board_presentation() -> MapPresentation {
507    catalog().defaults.quest_board.clone()
508}
509
510pub fn shallow_water_presentation() -> MapPresentation {
511    terrain_for(TerrainKindView::ShallowWater)
512}
513
514pub fn entity_fallback(label: &str) -> MapPresentation {
515    let c = catalog();
516    let initial = label
517        .chars()
518        .next()
519        .map(|ch| ch.to_ascii_uppercase().to_string())
520        .unwrap_or_else(|| "?".into());
521    if let Some(mut p) = c.entities.get("wildlife_other").cloned() {
522        p.glyph = initial.clone();
523        return p;
524    }
525    MapPresentation::new(initial, RgbColor::YELLOW)
526}
527
528/// One row in the `?` help map legend (glyphs from YAML catalogs).
529#[derive(Debug, Clone)]
530pub struct MapLegendEntry {
531    pub presentation: MapPresentation,
532    pub label: String,
533}
534
535fn legend_push(out: &mut Vec<MapLegendEntry>, pres: &MapPresentation, label: impl Into<String>) {
536    out.push(MapLegendEntry {
537        presentation: pres.clone(),
538        label: label.into(),
539    });
540}
541
542/// Map legend rows for help overlay and `flatland3 keys`.
543pub fn map_legend_entries() -> Vec<MapLegendEntry> {
544    let c = catalog();
545    let mut out = Vec::new();
546
547    legend_push(&mut out, &c.defaults.player, "you");
548    legend_push(&mut out, &c.defaults.loot, "ground loot");
549    legend_push(&mut out, &c.defaults.corpse, "carcass");
550    legend_push(&mut out, &c.defaults.door_closed, "door (closed)");
551    legend_push(&mut out, &c.defaults.door_open, "door (open)");
552    legend_push(&mut out, &c.defaults.wall, "wall");
553    legend_push(&mut out, &c.defaults.well_center, "well");
554    legend_push(&mut out, &c.defaults.resource_harvesting, "harvesting node");
555    legend_push(&mut out, &c.defaults.resource_cooldown, "node cooldown");
556
557    let mut terrain: Vec<_> = c.terrain.iter().collect();
558    terrain.sort_by_key(|(k, _)| *k);
559    if terrain.is_empty() {
560        for kind in [
561            TerrainKindView::Grass,
562            TerrainKindView::Hill,
563            TerrainKindView::Trail,
564            TerrainKindView::Rock,
565            TerrainKindView::Bog,
566            TerrainKindView::ShallowWater,
567            TerrainKindView::DeepWater,
568        ] {
569            let pres = terrain_for(kind);
570            legend_push(&mut out, &pres, terrain_key(kind).replace('_', " "));
571        }
572    } else {
573        for (kind, pres) in terrain {
574            legend_push(&mut out, pres, kind.replace('_', " "));
575        }
576    }
577
578    let mut npcs: Vec<_> = c.npcs.iter().collect();
579    npcs.sort_by_key(|(k, _)| *k);
580    for (id, pres) in npcs {
581        legend_push(&mut out, pres, format!("{id}"));
582    }
583
584    let mut items: Vec<_> = c.items.iter().collect();
585    items.sort_by_key(|(k, _)| *k);
586    for (id, pres) in items {
587        legend_push(&mut out, pres, id.replace('_', " "));
588    }
589
590    out
591}
592
593pub fn format_map_legend_plain() -> String {
594    let mut out = String::from("Map legend\n");
595    for entry in map_legend_entries() {
596        out.push_str(&format!(
597            "  {}  {}\n",
598            entry.presentation.glyph, entry.label
599        ));
600    }
601    out.push_str("  [red cell]  T1 combat target\n");
602    out.push_str("  [blue cell]  T2 combat target\n");
603    out
604}
605
606#[cfg(test)]
607mod tests {
608    use super::*;
609
610    #[test]
611    fn parses_named_and_hex_colors() {
612        assert_eq!(parse_color("cyan"), Some(RgbColor::CYAN));
613        assert_eq!(parse_color("#0af"), Some(RgbColor::rgb(0x00, 0xaa, 0xff)));
614    }
615
616    #[test]
617    fn terrain_and_resource_catalog_load() {
618        let oak = resource_for(&ResourceNodeView {
619            id: "t".into(),
620            label: "Oak".into(),
621            x: 0.0,
622            y: 0.0,
623            z: 0.0,
624            item_template: "oak_log".into(),
625            state: ResourceNodeState::Available,
626            blocking: false,
627            blocking_radius_m: 0.8,
628            tile_id: None,
629            sprite_mode: None,
630            presentation_state: None,
631        });
632        assert_eq!(oak.glyph, "?");
633
634        let rabbit = npc_for(&NpcView {
635            id: "r1".into(),
636            label: "Rabbit".into(),
637            role: "critter".into(),
638            x: 0.0,
639            y: 0.0,
640            building_id: None,
641            entity_id: Some(2),
642            life_state: None,
643            hp_pct: None,
644            can_trade: false,
645            tile_id: None,
646            behavior_state: None,
647            sprite_mode: None,
648            presentation_state: None,
649        });
650        assert_eq!(rabbit.glyph, "N");
651
652        let jack = npc_for(&NpcView {
653            id: "jack_wanderer".into(),
654            label: "Jack".into(),
655            role: "villager".into(),
656            x: 0.0,
657            y: 0.0,
658            building_id: None,
659            entity_id: None,
660            life_state: None,
661            hp_pct: None,
662            can_trade: false,
663            tile_id: None,
664            behavior_state: None,
665            sprite_mode: None,
666            presentation_state: None,
667        });
668        assert_eq!(jack.glyph, "N");
669        assert_eq!(jack.color, parse_color("cyan").expect("cyan"));
670    }
671
672    #[test]
673    fn trail_and_rock_use_distinct_glyphs_with_elevation_tint() {
674        let trail = terrain_for_elevation(TerrainKindView::Trail, 2.0);
675        assert_eq!(trail.glyph, ":");
676        let rock = terrain_for_elevation(TerrainKindView::Rock, 4.0);
677        assert_eq!(rock.glyph, "▓");
678        let low = terrain_for_elevation(TerrainKindView::Grass, 0.0);
679        let high = terrain_for_elevation(TerrainKindView::Grass, 4.0);
680        assert_ne!(low.color, high.color);
681    }
682
683    #[test]
684    fn terrain_for_zone_applies_glyph_and_color_overrides() {
685        let base = terrain_for_elevation(TerrainKindView::Grass, 0.0);
686        let custom = terrain_for_zone(
687            TerrainKindView::Grass,
688            0.0,
689            Some("%"),
690            Some("blue"),
691        );
692        assert_eq!(custom.glyph, "%");
693        assert_eq!(custom.color, parse_color("blue").expect("blue"));
694        assert_ne!(custom.glyph, base.glyph);
695        assert_ne!(custom.color, base.color);
696    }
697}