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