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