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 =
130 PathBuf::from(root).join(relative.strip_prefix("assets/").unwrap_or(relative));
131 if candidate.is_file() {
132 return candidate;
133 }
134 }
135 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
268fn 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), 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), }
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
404pub fn reload_map_presentation_catalog() {
406 *catalog_cell()
407 .write()
408 .expect("map presentation catalog lock") = load_catalog();
409}
410
411pub 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
443pub 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
483pub 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#[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
603pub 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 });
732 assert_eq!(rabbit.glyph, "N");
733
734 let jack = npc_for(&NpcView {
735 id: "jack_wanderer".into(),
736 label: "Jack".into(),
737 role: "villager".into(),
738 x: 0.0,
739 y: 0.0,
740 building_id: None,
741 entity_id: None,
742 life_state: None,
743 hp_pct: None,
744 can_trade: false,
745 buy_templates: Vec::new(),
746 tile_id: None,
747 behavior_state: None,
748 sprite_mode: None,
749 presentation_state: None,
750 paperdoll_ref: None,
751 draw_scale: 1.0,
752 yaw: None,
753 perception_fov_deg: None,
754 perception_sight_m: None,
755 perception_hear_m: None,
756 });
757 assert_eq!(jack.glyph, "N");
758 assert_eq!(jack.color, parse_color("cyan").expect("cyan"));
759 }
760
761 #[test]
762 fn trail_and_rock_use_distinct_glyphs_with_elevation_tint() {
763 let trail = terrain_for_elevation(TerrainKindView::Trail, 2.0);
764 assert_eq!(trail.glyph, ":");
765 let rock = terrain_for_elevation(TerrainKindView::Rock, 4.0);
766 assert_eq!(rock.glyph, "▓");
767 let low = terrain_for_elevation(TerrainKindView::Grass, 0.0);
768 let high = terrain_for_elevation(TerrainKindView::Grass, 4.0);
769 assert_ne!(low.color, high.color);
770 }
771
772 #[test]
773 fn terrain_for_zone_applies_glyph_and_color_overrides() {
774 let base = terrain_for_elevation(TerrainKindView::Grass, 0.0);
775 let custom = terrain_for_zone(TerrainKindView::Grass, 0.0, Some("%"), Some("blue"));
776 assert_eq!(custom.glyph, "%");
777 assert_eq!(custom.color, parse_color("blue").expect("blue"));
778 assert_ne!(custom.glyph, base.glyph);
779 assert_ne!(custom.color, base.color);
780 }
781}