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