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 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 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
262fn 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), 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), }
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
397pub fn reload_map_presentation_catalog() {
399 *catalog_cell()
400 .write()
401 .expect("map presentation catalog lock") = load_catalog();
402}
403
404pub 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
436pub 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
476pub 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#[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
594pub 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}