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(yaml: Option<GlyphColorYaml>, fallback: &MapPresentation) -> MapPresentation {
181    yaml.and_then(|d| parse_entry(&d))
182        .unwrap_or_else(|| fallback.clone())
183}
184
185fn builtin_terrain(kind: TerrainKindView) -> MapPresentation {
186    match kind {
187        TerrainKindView::Grass => MapPresentation::new(".", RgbColor::rgb(0x5a, 0x73, 0x56)),
188        TerrainKindView::Dirt => MapPresentation::new(".", RgbColor::rgb(0x8a, 0x70, 0x4a)),
189        TerrainKindView::Tilled => MapPresentation::new("=", RgbColor::rgb(0x6e, 0x58, 0x38)),
190        TerrainKindView::Desert => MapPresentation::new(".", RgbColor::rgb(0xd4, 0xc0, 0x7a)),
191        TerrainKindView::Hill => MapPresentation::new("^", RgbColor::rgb(0x9a, 0x92, 0x68)),
192        TerrainKindView::Trail => MapPresentation::new(":", RgbColor::rgb(0xc4, 0xb8, 0x90)),
193        TerrainKindView::Road => MapPresentation::new("=", RgbColor::rgb(0xb0, 0xa0, 0x78)),
194        TerrainKindView::Rock => MapPresentation::new("#", RgbColor::rgb(0x5a, 0x50, 0x48)),
195        TerrainKindView::Bog => MapPresentation::new(",", RgbColor::MAGENTA),
196        TerrainKindView::ShallowWater => MapPresentation::new("~", RgbColor::CYAN),
197        TerrainKindView::DeepWater => MapPresentation::new("~", RgbColor::BLUE),
198    }
199}
200
201fn terrain_key(kind: TerrainKindView) -> &'static str {
202    match kind {
203        TerrainKindView::Grass => "grass",
204        TerrainKindView::Dirt => "dirt",
205        TerrainKindView::Tilled => "tilled",
206        TerrainKindView::Desert => "desert",
207        TerrainKindView::Hill => "hill",
208        TerrainKindView::Trail => "trail",
209        TerrainKindView::Road => "road",
210        TerrainKindView::Rock => "rock",
211        TerrainKindView::Bog => "bog",
212        TerrainKindView::ShallowWater => "shallow_water",
213        TerrainKindView::DeepWater => "deep_water",
214    }
215}
216
217/// Ground elevation → hue ladder (low cool green → mid olive → high warm tan → snow).
218fn elevation_palette(elevation: f32) -> RgbColor {
219    let bucket = elevation.round().clamp(-3.0, 8.0) as i32;
220    match bucket {
221        b if b <= -1 => RgbColor::rgb(0x3d, 0x58, 0x6a), // below grade — cool slate
222        0 => RgbColor::rgb(0x4a, 0x6b, 0x42),            // lowland green
223        1 => RgbColor::rgb(0x62, 0x7a, 0x48),            // foothill
224        2 => RgbColor::rgb(0x7a, 0x86, 0x4c),            // slope olive
225        3 => RgbColor::rgb(0x96, 0x8a, 0x58),            // upper slope tan-green
226        4 => RgbColor::rgb(0xae, 0x8c, 0x62),            // ridge / plateau
227        5 => RgbColor::rgb(0xc8, 0xa4, 0x72),            // high scree
228        6 => RgbColor::rgb(0xde, 0xc0, 0x90),            // near summit
229        _ => RgbColor::rgb(0xec, 0xea, 0xf4),            // snow cap
230    }
231}
232
233fn darken_rgb(color: RgbColor, factor: f32) -> RgbColor {
234    RgbColor::rgb(
235        (color.r as f32 * factor) as u8,
236        (color.g as f32 * factor) as u8,
237        (color.b as f32 * factor) as u8,
238    )
239}
240
241fn lighten_rgb(color: RgbColor, factor: f32) -> RgbColor {
242    RgbColor::rgb(
243        ((color.r as f32 * factor).min(255.0)) as u8,
244        ((color.g as f32 * factor).min(255.0)) as u8,
245        ((color.b as f32 * factor).min(255.0)) as u8,
246    )
247}
248
249fn load_item_presentations() -> HashMap<String, MapPresentation> {
250    let path = find_assets_dir("assets/items");
251    let Ok(entries) = std::fs::read_dir(&path) else {
252        return HashMap::new();
253    };
254    let fallback = RgbColor::rgb(0x8a, 0x8a, 0x8a);
255    let mut out = HashMap::new();
256    let mut files: Vec<PathBuf> = entries
257        .filter_map(|e| e.ok())
258        .map(|e| e.path())
259        .filter(|p| {
260            p.is_file()
261                && p.extension()
262                    .is_some_and(|ext| ext == "yaml" || ext == "yml")
263        })
264        .collect();
265    files.sort();
266    for file in files {
267        let Ok(raw) = std::fs::read_to_string(&file) else {
268            continue;
269        };
270        let Ok(root) = serde_yaml::from_str::<ItemGlyphsFile>(&raw) else {
271            continue;
272        };
273        for item in root.items {
274            let Some(glyph) = item.glyph else {
275                continue;
276            };
277            let color = item
278                .color
279                .as_deref()
280                .and_then(parse_color)
281                .unwrap_or(fallback);
282            out.insert(item.template_id, MapPresentation::new(glyph, color));
283        }
284    }
285    out
286}
287
288fn load_catalog() -> Catalog {
289    let mut terrain = HashMap::new();
290    if let Ok(raw) = std::fs::read_to_string(find_assets_path("assets/world/terrain-kinds.yaml")) {
291        if let Ok(file) = serde_yaml::from_str::<TerrainKindsFile>(&raw) {
292            terrain = parse_map(file.terrain_kinds);
293        }
294    }
295
296    let mut defaults = builtin_defaults();
297    let items = load_item_presentations();
298    let mut npcs = HashMap::new();
299    let mut entities = HashMap::new();
300
301    if let Ok(raw) = std::fs::read_to_string(find_assets_path("assets/world/map-glyphs.yaml")) {
302        if let Ok(file) = serde_yaml::from_str::<MapGlyphsFile>(&raw) {
303            let base = builtin_defaults();
304            defaults = DefaultsBlock {
305                resource: merge_default(file.defaults.resource, &base.resource),
306                resource_harvesting: merge_default(
307                    file.defaults.resource_harvesting,
308                    &base.resource_harvesting,
309                ),
310                resource_cooldown: merge_default(
311                    file.defaults.resource_cooldown,
312                    &base.resource_cooldown,
313                ),
314                npc_wildlife: merge_default(file.defaults.npc_wildlife, &base.npc_wildlife),
315                npc_friendly: merge_default(file.defaults.npc_friendly, &base.npc_friendly),
316                player: merge_default(file.defaults.player, &base.player),
317                corpse: merge_default(file.defaults.corpse, &base.corpse),
318                loot: merge_default(file.defaults.loot, &base.loot),
319                chest: merge_default(file.defaults.chest, &base.chest),
320                chest_locked: merge_default(file.defaults.chest_locked, &base.chest_locked),
321                door_closed: merge_default(file.defaults.door_closed, &base.door_closed),
322                door_open: merge_default(file.defaults.door_open, &base.door_open),
323                wall: merge_default(file.defaults.wall, &base.wall),
324                well_center: merge_default(file.defaults.well_center, &base.well_center),
325                quest_board: merge_default(file.defaults.quest_board, &base.quest_board),
326            };
327            npcs = parse_map(file.npcs);
328            entities = parse_map(file.entities);
329        }
330    }
331
332    Catalog {
333        terrain,
334        defaults,
335        items,
336        npcs,
337        entities,
338    }
339}
340
341fn catalog_cell() -> &'static RwLock<Catalog> {
342    static CELL: OnceLock<RwLock<Catalog>> = OnceLock::new();
343    CELL.get_or_init(|| RwLock::new(load_catalog()))
344}
345
346fn catalog() -> std::sync::RwLockReadGuard<'static, Catalog> {
347    catalog_cell()
348        .read()
349        .expect("map presentation catalog lock")
350}
351
352/// Reload `terrain-kinds.yaml`, `map-glyphs.yaml`, and item glyphs from disk.
353pub fn reload_map_presentation_catalog() {
354    *catalog_cell()
355        .write()
356        .expect("map presentation catalog lock") = load_catalog();
357}
358
359/// Reload presentation YAML when the sim reports a newer content revision (after publish).
360pub fn maybe_reload_for_content_rev(content_rev: u64) {
361    static LAST_REV: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
362    let prev = LAST_REV.load(std::sync::atomic::Ordering::Relaxed);
363    if content_rev > 0 && content_rev != prev {
364        LAST_REV.store(content_rev, std::sync::atomic::Ordering::Relaxed);
365        reload_map_presentation_catalog();
366    }
367}
368
369fn npc_glyph_lookup(c: &Catalog, npc: &NpcView) -> Option<MapPresentation> {
370    let id_key = npc.id.to_ascii_lowercase();
371    if let Some(p) = c.npcs.get(&id_key) {
372        return Some(p.clone());
373    }
374    if let Some(prefix) = id_key.split('_').next() {
375        if let Some(p) = c.npcs.get(prefix) {
376            return Some(p.clone());
377        }
378    }
379    let label_key = npc.label.to_ascii_lowercase();
380    c.npcs.get(&label_key).cloned()
381}
382
383pub fn terrain_for(kind: TerrainKindView) -> MapPresentation {
384    catalog()
385        .terrain
386        .get(terrain_key(kind))
387        .cloned()
388        .unwrap_or_else(|| builtin_terrain(kind))
389}
390
391/// Terrain glyph + color from zone kind and ground elevation (topographic read).
392pub fn terrain_for_elevation(kind: TerrainKindView, elevation: f32) -> MapPresentation {
393    match kind {
394        TerrainKindView::ShallowWater | TerrainKindView::DeepWater | TerrainKindView::Bog => {
395            return terrain_for(kind);
396        }
397        TerrainKindView::Trail => {
398            let color = lighten_rgb(elevation_palette(elevation), 1.12);
399            return MapPresentation::new(":", color);
400        }
401        TerrainKindView::Road => {
402            let color = lighten_rgb(elevation_palette(elevation), 1.22);
403            return MapPresentation::new("=", color);
404        }
405        TerrainKindView::Rock => {
406            let color = darken_rgb(elevation_palette(elevation), 0.62);
407            let glyph = if elevation >= 3.5 { "▓" } else { "#" };
408            return MapPresentation::new(glyph, color);
409        }
410        _ => {}
411    }
412
413    let base = terrain_for(kind);
414    let color = elevation_palette(elevation);
415    if elevation.abs() < 0.25 {
416        return MapPresentation::new(base.glyph, color);
417    }
418
419    let bucket = elevation.round().clamp(-9.0, 9.0) as i32;
420    let ch = match bucket {
421        b if b >= 6 => '▲',
422        b if b >= 4 => '▲',
423        b if b >= 2 => '^',
424        b if b >= 1 => '▴',
425        b if b <= -1 => '▾',
426        _ => base.glyph.chars().next().unwrap_or('.'),
427    };
428    MapPresentation::new(ch.to_string(), color)
429}
430
431/// Terrain presentation for a zone cell, applying optional per-zone glyph/color overrides from YAML.
432pub fn terrain_for_zone(
433    kind: TerrainKindView,
434    elevation: f32,
435    glyph_override: Option<&str>,
436    color_override: Option<&str>,
437) -> MapPresentation {
438    let mut pres = terrain_for_elevation(kind, elevation);
439    if let Some(g) = glyph_override.map(str::trim).filter(|s| !s.is_empty()) {
440        let ch = g.chars().next().unwrap_or('.');
441        pres.glyph = ch.to_string();
442    }
443    if let Some(raw) = color_override.map(str::trim).filter(|s| !s.is_empty()) {
444        if let Some(color) = parse_color(raw) {
445            pres.color = color;
446        }
447    }
448    pres
449}
450
451pub fn resource_for(node: &ResourceNodeView) -> MapPresentation {
452    let c = catalog();
453    match node.state {
454        ResourceNodeState::Harvesting => c.defaults.resource_harvesting.clone(),
455        ResourceNodeState::Cooldown => c.defaults.resource_cooldown.clone(),
456        ResourceNodeState::Available => c
457            .items
458            .get(&node.item_template)
459            .cloned()
460            .unwrap_or_else(|| c.defaults.resource.clone()),
461    }
462}
463
464pub fn npc_for(npc: &NpcView) -> MapPresentation {
465    let c = catalog();
466    if let Some(p) = npc_glyph_lookup(&c, npc) {
467        return p;
468    }
469    if npc.entity_id.is_some() {
470        return c.defaults.npc_wildlife.clone();
471    }
472    c.defaults.npc_friendly.clone()
473}
474
475pub fn player_presentation() -> MapPresentation {
476    catalog().defaults.player.clone()
477}
478
479pub fn corpse_presentation() -> MapPresentation {
480    catalog().defaults.corpse.clone()
481}
482
483pub fn loot_presentation() -> MapPresentation {
484    catalog().defaults.loot.clone()
485}
486
487pub fn chest_presentation(locked: bool) -> MapPresentation {
488    let c = catalog();
489    if locked {
490        c.defaults.chest_locked.clone()
491    } else {
492        c.defaults.chest.clone()
493    }
494}
495
496pub fn door_presentation(open: bool) -> MapPresentation {
497    let c = catalog();
498    if open {
499        c.defaults.door_open.clone()
500    } else {
501        c.defaults.door_closed.clone()
502    }
503}
504
505pub fn wall_presentation() -> MapPresentation {
506    catalog().defaults.wall.clone()
507}
508
509pub fn well_center_presentation() -> MapPresentation {
510    catalog().defaults.well_center.clone()
511}
512
513pub fn quest_board_presentation() -> MapPresentation {
514    catalog().defaults.quest_board.clone()
515}
516
517pub fn shallow_water_presentation() -> MapPresentation {
518    terrain_for(TerrainKindView::ShallowWater)
519}
520
521pub fn entity_fallback(label: &str) -> MapPresentation {
522    let c = catalog();
523    let initial = label
524        .chars()
525        .next()
526        .map(|ch| ch.to_ascii_uppercase().to_string())
527        .unwrap_or_else(|| "?".into());
528    if let Some(mut p) = c.entities.get("wildlife_other").cloned() {
529        p.glyph = initial.clone();
530        return p;
531    }
532    MapPresentation::new(initial, RgbColor::YELLOW)
533}
534
535/// One row in the `?` help map legend (glyphs from YAML catalogs).
536#[derive(Debug, Clone)]
537pub struct MapLegendEntry {
538    pub presentation: MapPresentation,
539    pub label: String,
540}
541
542fn legend_push(out: &mut Vec<MapLegendEntry>, pres: &MapPresentation, label: impl Into<String>) {
543    out.push(MapLegendEntry {
544        presentation: pres.clone(),
545        label: label.into(),
546    });
547}
548
549/// Map legend rows for help overlay and `flatland3 keys`.
550pub fn map_legend_entries() -> Vec<MapLegendEntry> {
551    let c = catalog();
552    let mut out = Vec::new();
553
554    legend_push(&mut out, &c.defaults.player, "you");
555    legend_push(&mut out, &c.defaults.loot, "ground loot");
556    legend_push(&mut out, &c.defaults.corpse, "carcass");
557    legend_push(&mut out, &c.defaults.door_closed, "door (closed)");
558    legend_push(&mut out, &c.defaults.door_open, "door (open)");
559    legend_push(&mut out, &c.defaults.wall, "wall");
560    legend_push(&mut out, &c.defaults.well_center, "well");
561    legend_push(&mut out, &c.defaults.resource_harvesting, "harvesting node");
562    legend_push(&mut out, &c.defaults.resource_cooldown, "node cooldown");
563
564    let mut terrain: Vec<_> = c.terrain.iter().collect();
565    terrain.sort_by_key(|(k, _)| *k);
566    if terrain.is_empty() {
567        for kind in [
568            TerrainKindView::Grass,
569            TerrainKindView::Dirt,
570            TerrainKindView::Tilled,
571            TerrainKindView::Desert,
572            TerrainKindView::Hill,
573            TerrainKindView::Trail,
574            TerrainKindView::Road,
575            TerrainKindView::Rock,
576            TerrainKindView::Bog,
577            TerrainKindView::ShallowWater,
578            TerrainKindView::DeepWater,
579        ] {
580            let pres = terrain_for(kind);
581            legend_push(&mut out, &pres, terrain_key(kind).replace('_', " "));
582        }
583    } else {
584        for (kind, pres) in terrain {
585            legend_push(&mut out, pres, kind.replace('_', " "));
586        }
587    }
588
589    let mut npcs: Vec<_> = c.npcs.iter().collect();
590    npcs.sort_by_key(|(k, _)| *k);
591    for (id, pres) in npcs {
592        legend_push(&mut out, pres, format!("{id}"));
593    }
594
595    let mut items: Vec<_> = c.items.iter().collect();
596    items.sort_by_key(|(k, _)| *k);
597    for (id, pres) in items {
598        legend_push(&mut out, pres, id.replace('_', " "));
599    }
600
601    out
602}
603
604pub fn format_map_legend_plain() -> String {
605    let mut out = String::from("Map legend\n");
606    for entry in map_legend_entries() {
607        out.push_str(&format!(
608            "  {}  {}\n",
609            entry.presentation.glyph, entry.label
610        ));
611    }
612    out.push_str("  [red cell]  T1 combat target\n");
613    out.push_str("  [blue cell]  T2 combat target\n");
614    out
615}
616
617#[cfg(test)]
618mod tests {
619    use super::*;
620
621    #[test]
622    fn parses_named_and_hex_colors() {
623        assert_eq!(parse_color("cyan"), Some(RgbColor::CYAN));
624        assert_eq!(parse_color("#0af"), Some(RgbColor::rgb(0x00, 0xaa, 0xff)));
625    }
626
627    #[test]
628    fn terrain_and_resource_catalog_load() {
629        let oak = resource_for(&ResourceNodeView {
630            id: "t".into(),
631            label: "Oak".into(),
632            x: 0.0,
633            y: 0.0,
634            z: 0.0,
635            item_template: "oak_log".into(),
636            state: ResourceNodeState::Available,
637            blocking: false,
638            blocking_radius_m: 0.8,
639            tile_id: None,
640            paperdoll_ref: None,
641            yaw: 0.0,
642            pitch: 0.0,
643            roll: 0.0,
644            draw_scale: 1.0,
645            sprite_mode: None,
646            presentation_state: None,
647        });
648        assert_eq!(oak.glyph, "?");
649
650        let rabbit = npc_for(&NpcView {
651            id: "r1".into(),
652            label: "Rabbit".into(),
653            role: "critter".into(),
654            x: 0.0,
655            y: 0.0,
656            building_id: None,
657            entity_id: Some(2),
658            life_state: None,
659            hp_pct: None,
660            can_trade: false,
661            tile_id: None,
662            behavior_state: None,
663            sprite_mode: None,
664            presentation_state: None,
665            paperdoll_ref: None,
666        });
667        assert_eq!(rabbit.glyph, "N");
668
669        let jack = npc_for(&NpcView {
670            id: "jack_wanderer".into(),
671            label: "Jack".into(),
672            role: "villager".into(),
673            x: 0.0,
674            y: 0.0,
675            building_id: None,
676            entity_id: None,
677            life_state: None,
678            hp_pct: None,
679            can_trade: false,
680            tile_id: None,
681            behavior_state: None,
682            sprite_mode: None,
683            presentation_state: None,
684            paperdoll_ref: None,
685        });
686        assert_eq!(jack.glyph, "N");
687        assert_eq!(jack.color, parse_color("cyan").expect("cyan"));
688    }
689
690    #[test]
691    fn trail_and_rock_use_distinct_glyphs_with_elevation_tint() {
692        let trail = terrain_for_elevation(TerrainKindView::Trail, 2.0);
693        assert_eq!(trail.glyph, ":");
694        let rock = terrain_for_elevation(TerrainKindView::Rock, 4.0);
695        assert_eq!(rock.glyph, "▓");
696        let low = terrain_for_elevation(TerrainKindView::Grass, 0.0);
697        let high = terrain_for_elevation(TerrainKindView::Grass, 4.0);
698        assert_ne!(low.color, high.color);
699    }
700
701    #[test]
702    fn terrain_for_zone_applies_glyph_and_color_overrides() {
703        let base = terrain_for_elevation(TerrainKindView::Grass, 0.0);
704        let custom = terrain_for_zone(TerrainKindView::Grass, 0.0, Some("%"), Some("blue"));
705        assert_eq!(custom.glyph, "%");
706        assert_eq!(custom.color, parse_color("blue").expect("blue"));
707        assert_ne!(custom.glyph, base.glyph);
708        assert_ne!(custom.color, base.color);
709    }
710}