1use 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
217fn 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), 0 => RgbColor::rgb(0x4a, 0x6b, 0x42), 1 => RgbColor::rgb(0x62, 0x7a, 0x48), 2 => RgbColor::rgb(0x7a, 0x86, 0x4c), 3 => RgbColor::rgb(0x96, 0x8a, 0x58), 4 => RgbColor::rgb(0xae, 0x8c, 0x62), 5 => RgbColor::rgb(0xc8, 0xa4, 0x72), 6 => RgbColor::rgb(0xde, 0xc0, 0x90), _ => RgbColor::rgb(0xec, 0xea, 0xf4), }
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
352pub fn reload_map_presentation_catalog() {
354 *catalog_cell()
355 .write()
356 .expect("map presentation catalog lock") = load_catalog();
357}
358
359pub 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
391pub 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
431pub 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#[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
549pub 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}