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