use ratatui::Frame;
use ratatui::layout::Rect;
use ratatui::style::{Color, Modifier, Style, Stylize};
use ratatui::text::{Line, Span};
use ratatui::widgets::Paragraph;
use crate::world::types::Biome;
use super::app::WorldbuilderApp;
fn biome_cell(b: Biome) -> (char, Color) {
match b {
Biome::Ocean => ('~', Color::Blue),
Biome::IceCap => ('*', Color::White),
Biome::Tundra => ('-', Color::Gray),
Biome::Taiga => ('t', Color::Green),
Biome::TemperateForest => ('T', Color::Green),
Biome::TemperateGrassland => ('"', Color::LightGreen),
Biome::Mediterranean => ('m', Color::LightYellow),
Biome::ColdDesert => (',', Color::Gray),
Biome::HotDesert => (':', Color::Yellow),
Biome::Savanna => (';', Color::LightYellow),
Biome::TropicalSeasonal => ('w', Color::LightGreen),
Biome::TropicalRainforest => ('#', Color::Green),
}
}
fn compose(
layers: &crate::world::plausibility::CompiledLayers,
map_w: usize,
map_h: usize,
) -> Vec<Vec<(char, Color)>> {
let climate = &layers.climate;
let (sw, sh) = (climate.width, climate.height);
let hydro = &layers.hydrology;
let has_rivers = hydro.is_river.len() == sw * sh;
let mut town_at = vec![0u8; map_w * map_h];
for s in &layers.demographics.settlements {
if s.x >= sw || s.y >= sh {
continue;
}
let ox = (s.x * map_w / sw).min(map_w - 1);
let oy = (s.y * map_h / sh).min(map_h - 1);
let rank = if s.class == "city" { 2 } else { 1 };
let slot = &mut town_at[oy * map_w + ox];
if rank > *slot {
*slot = rank;
}
}
let mut grid = Vec::with_capacity(map_h);
for oy in 0..map_h {
let sy = oy * sh / map_h;
let mut row = Vec::with_capacity(map_w);
for ox in 0..map_w {
let sx = ox * sw / map_w;
let idx = sy * sw + sx;
let cell = match town_at[oy * map_w + ox] {
2 => ('◉', Color::LightRed),
1 => ('•', Color::Red),
_ if has_rivers && hydro.is_river[idx] && climate.biome[idx] != Biome::Ocean => {
('≈', Color::Cyan)
}
_ => biome_cell(climate.biome[idx]),
};
row.push(cell);
}
grid.push(row);
}
grid
}
pub(super) fn click_to_source(
inner: ratatui::layout::Rect,
col: u16,
row: u16,
sw: usize,
sh: usize,
) -> Option<(usize, usize)> {
if col < inner.x
|| row < inner.y
|| col >= inner.x + inner.width
|| row >= inner.y + inner.height
{
return None;
}
let map_w = inner.width as usize;
let map_h = (inner.height as usize).saturating_sub(2);
if map_w == 0 || map_h == 0 || sw == 0 || sh == 0 {
return None;
}
let dy = (row - inner.y) as usize;
if dy >= map_h {
return None;
}
let dx = (col - inner.x) as usize;
Some(((dx * sw / map_w).min(sw - 1), (dy * sh / map_h).min(sh - 1)))
}
pub(super) fn source_to_display(
(sx, sy): (usize, usize),
(sw, sh): (usize, usize),
(dw, dh): (usize, usize),
) -> (usize, usize) {
if sw == 0 || sh == 0 || dw == 0 || dh == 0 {
return (0, 0);
}
let dx = (sx * dw / sw).min(dw - 1);
let dy = (sy * dh / sh).min(dh - 1);
(dx, dy)
}
pub(super) fn apply_brush(
hm: &mut [f32],
w: usize,
h: usize,
cx: usize,
cy: usize,
radius: usize,
delta: f32,
) {
let r = radius as i32;
let rr = (radius as f32).max(1.0);
for dy in -r..=r {
for dx in -r..=r {
let d = ((dx * dx + dy * dy) as f32).sqrt();
if d > rr {
continue;
}
let (x, y) = (cx as i32 + dx, cy as i32 + dy);
if x < 0 || y < 0 || x >= w as i32 || y >= h as i32 {
continue;
}
let falloff = 1.0 - d / rr;
let idx = y as usize * w + x as usize;
hm[idx] = (hm[idx] + delta * falloff).clamp(0.0, 1.0);
}
}
}
fn terrain_cell(e: f32, sea: f32) -> (char, Color) {
if e <= sea {
return ('~', Color::Blue);
}
let relief = ((e - sea) / (1.0 - sea).max(1e-3)).clamp(0.0, 1.0);
if relief < 0.33 {
('.', Color::Green)
} else if relief < 0.66 {
('^', Color::Yellow)
} else {
('A', Color::White)
}
}
fn compose_terrain(
terrain: &[f32],
sea: f32,
sw: usize,
sh: usize,
map_w: usize,
map_h: usize,
) -> Vec<Vec<(char, Color)>> {
let mut grid = Vec::with_capacity(map_h);
for oy in 0..map_h {
let sy = oy * sh / map_h;
let mut row = Vec::with_capacity(map_w);
for ox in 0..map_w {
let sx = ox * sw / map_w;
let e = terrain.get(sy * sw + sx).copied().unwrap_or(0.0);
row.push(terrain_cell(e, sea));
}
grid.push(row);
}
grid
}
pub(super) struct MapFinding {
pub text: String,
pub at: Option<(usize, usize)>,
}
pub(super) fn lint_map(
def: &crate::world::types::WorldDefinition,
layers: &crate::world::plausibility::CompiledLayers,
) -> Vec<MapFinding> {
let c = &layers.climate;
let (w, h) = (c.width, c.height);
let is_ocean = |x: usize, y: usize| -> bool {
x < w && y < h && c.biome.get(y * w + x).copied() == Some(Biome::Ocean)
};
let mut out = Vec::new();
let Some(g) = def.geography.as_ref() else { return out };
for lm in &g.landmarks {
if let (Some(rx), Some(ry)) = (lm.x, lm.y) {
if rx >= w || ry >= h {
out.push(MapFinding {
text: format!("landmark '{}' is off the map ({rx},{ry})", lm.name),
at: lm.grid(w, h),
});
continue;
}
}
match lm.grid(w, h) {
Some((x, y)) if is_ocean(x, y) => {
let noun = if matches!(lm.kind.as_str(), "city" | "port" | "town") {
"settlement"
} else {
"landmark"
};
out.push(MapFinding {
text: format!("{noun} '{}' sits in open ocean at ({x},{y})", lm.name),
at: Some((x, y)),
});
}
_ => {}
}
}
for r in &g.regions {
if let (Some(x), Some(y)) = (r.x, r.y) {
let (x, y) = (x.min(w.saturating_sub(1)), y.min(h.saturating_sub(1)));
if is_ocean(x, y) {
out.push(MapFinding {
text: format!("region '{}' is in the sea at ({x},{y})", r.name),
at: Some((x, y)),
});
}
}
}
out
}
pub(super) fn line_cells(a: (usize, usize), b: (usize, usize)) -> Vec<(usize, usize)> {
let (x0, y0) = (a.0 as i32, a.1 as i32);
let (x1, y1) = (b.0 as i32, b.1 as i32);
let n = (x1 - x0).abs().max((y1 - y0).abs()).max(1);
(0..=n)
.map(|i| {
let t = i as f32 / n as f32;
let x = (x0 as f32 + (x1 - x0) as f32 * t).round().max(0.0) as usize;
let y = (y0 as f32 + (y1 - y0) as f32 * t).round().max(0.0) as usize;
(x, y)
})
.collect()
}
pub(super) fn render_map(frame: &mut Frame, app: &WorldbuilderApp, area: Rect) {
let Some(layers) = app.compiled_layers.as_ref() else {
frame.render_widget(
Paragraph::new(Span::styled(
"Run /compile for the ASCII map, or /map for the plakat raster.",
Style::new().dim(),
)),
area,
);
return;
};
let climate = &layers.climate;
let (sw, sh) = (climate.width, climate.height);
if sw == 0 || sh == 0 || climate.biome.len() != sw * sh {
frame.render_widget(
Paragraph::new(Span::styled("(empty climate grid)", Style::new().dim())),
area,
);
return;
}
let map_h = area.height.saturating_sub(2) as usize;
let map_w = area.width as usize;
if map_h == 0 || map_w == 0 {
return;
}
let hydro = &layers.hydrology;
let terrain_preview = app.map_terrain.as_ref().filter(|t| t.len() == sw * sh);
let grid = match terrain_preview {
Some(t) => compose_terrain(t, app.map_terrain_sea, sw, sh, map_w, map_h),
None => compose(layers, map_w, map_h),
};
let cursor_disp = if app.map_edit {
Some(source_to_display(app.map_cursor, (sw, sh), (map_w, map_h)))
} else {
None
};
let mut landmark_cells: std::collections::HashSet<(usize, usize)> = std::collections::HashSet::new();
for m in &app.map_landmarks {
if m.x < sw && m.y < sh {
landmark_cells.insert(source_to_display((m.x, m.y), (sw, sh), (map_w, map_h)));
}
}
let mut region_cells: std::collections::HashSet<(usize, usize)> = std::collections::HashSet::new();
for m in &app.map_regions {
if m.x < sw && m.y < sh {
region_cells.insert(source_to_display((m.x, m.y), (sw, sh), (map_w, map_h)));
}
}
let mut road_cells: std::collections::HashSet<(usize, usize)> = std::collections::HashSet::new();
for (a, b) in &app.map_roads {
if a.0 < sw && a.1 < sh && b.0 < sw && b.1 < sh {
let ad = source_to_display(*a, (sw, sh), (map_w, map_h));
let bd = source_to_display(*b, (sw, sh), (map_w, map_h));
for c in line_cells(ad, bd) {
road_cells.insert(c);
}
}
}
let mut finding_cells: std::collections::HashSet<(usize, usize)> = std::collections::HashSet::new();
for f in &app.map_findings {
if let Some((x, y)) = f.at {
if x < sw && y < sh {
finding_cells.insert(source_to_display((x, y), (sw, sh), (map_w, map_h)));
}
}
}
let mut river_src: Option<(usize, usize)> = None;
let mut river_line: std::collections::HashSet<(usize, usize)> = std::collections::HashSet::new();
if let Some(super::app::MapTool::River { source: Some(src) }) = &app.map_tool {
if src.0 < sw && src.1 < sh {
let sd = source_to_display(*src, (sw, sh), (map_w, map_h));
river_src = Some(sd);
if let Some(cd) = cursor_disp {
for cell in line_cells(sd, cd) {
river_line.insert(cell);
}
}
}
}
if let Some(super::app::MapTool::Road { from: Some(name) }) = &app.map_tool {
if let Some(m) = app.map_landmarks.iter().find(|m| &m.name == name) {
if m.x < sw && m.y < sh {
let sd = source_to_display((m.x, m.y), (sw, sh), (map_w, map_h));
river_src = Some(sd);
if let Some(cd) = cursor_disp {
for cell in line_cells(sd, cd) {
river_line.insert(cell);
}
}
}
}
}
let mut lines: Vec<Line> = Vec::with_capacity(map_h + 2);
for (y, row) in grid.iter().enumerate() {
let spans: Vec<Span> = row
.iter()
.enumerate()
.map(|(x, &(ch, color))| {
let (mut ch, mut color) = (ch, color);
if road_cells.contains(&(x, y)) {
ch = '=';
color = Color::Yellow;
}
if river_line.contains(&(x, y)) {
ch = '·';
color = Color::Cyan;
}
if region_cells.contains(&(x, y)) {
ch = '§';
color = Color::LightMagenta;
}
if landmark_cells.contains(&(x, y)) {
ch = '⌂';
color = Color::Magenta;
}
if river_src == Some((x, y)) {
ch = 'S';
color = Color::Cyan;
}
if finding_cells.contains(&(x, y)) {
ch = '!';
color = Color::Red;
}
let mut st = Style::new().fg(color);
if cursor_disp == Some((x, y)) {
st = st.add_modifier(Modifier::REVERSED);
}
Span::styled(ch.to_string(), st)
})
.collect();
lines.push(Line::from(spans));
}
if app.map_edit {
let (cx, cy) = app.map_cursor;
let idx = cy.min(sh - 1) * sw + cx.min(sw - 1);
let biome = climate.biome.get(idx).map(|b| b.as_str()).unwrap_or("?");
let elev = layers.geology.heightmap.get(idx).copied().unwrap_or(0.0);
let sea = if elev <= layers.geology.sea_level { " · sea" } else { "" };
let here = app
.map_landmarks
.iter()
.find(|m| (m.x, m.y) == (cx, cy))
.map(|m| format!(" · ⌂ {} ({})", m.name, m.kind))
.or_else(|| {
app.map_regions
.iter()
.find(|m| (m.x, m.y) == (cx, cy))
.map(|m| format!(" · § {} ({})", m.name, m.kind))
})
.unwrap_or_default();
let terrain_tag = if terrain_preview.is_some() {
format!(" · ⛰ terrain r{}", app.map_brush)
} else {
String::new()
};
lines.push(Line::from(Span::styled(
format!("✎ ({cx},{cy}) · {biome} · elev {elev:.2}{sea}{here}{terrain_tag}"),
Style::new().fg(Color::Yellow),
)));
let hint = match &app.map_tool {
Some(super::app::MapTool::River { source: None }) => {
"river: move to the SOURCE, Enter to set · Esc cancel"
}
Some(super::app::MapTool::River { source: Some(_) }) => {
"river: move to the MOUTH, Enter to set · Esc cancel"
}
Some(super::app::MapTool::Road { from: None }) => {
"road: move to the first landmark, Enter · Esc cancel"
}
Some(super::app::MapTool::Road { from: Some(_) }) => {
"road: move to the other landmark, Enter · Esc cancel"
}
None => "hjkl · t/n/g/r/o place · +/− terrain (/terrain saves) · d del · f issue · Esc",
};
lines.push(Line::from(Span::styled(hint, Style::new().dim())));
} else {
lines.push(Line::from(Span::styled(
format!(
"grid {sw}×{sh} → {map_w}×{map_h} · {} river cell(s) · {} settlement(s) · e: edit",
hydro.river_count,
layers.demographics.settlements.len(),
),
Style::new().dim(),
)));
lines.push(Line::from(vec![
Span::styled("~", Style::new().fg(Color::Blue)),
Span::raw(" sea "),
Span::styled("≈", Style::new().fg(Color::Cyan)),
Span::raw(" river "),
Span::styled("#T", Style::new().fg(Color::Green)),
Span::raw(" forest "),
Span::styled(":", Style::new().fg(Color::Yellow)),
Span::raw(" desert "),
Span::styled("⌂", Style::new().fg(Color::Magenta)),
Span::raw(" landmark"),
]));
}
frame.render_widget(Paragraph::new(lines), area);
}
#[cfg(test)]
mod tests {
use super::*;
use crate::world::plausibility::compile_layers;
use crate::world::types::WorldDefinition;
fn terra() -> WorldDefinition {
let body = r#"{
name: "Terra"
seed: 0x5151
astronomy: {
star: { luminosity_solar: 1.0 }
planet: { mass_earth: 1.0, radius_earth: 1.0, axial_tilt_deg: 23.4, day_length_hours: 24.0 }
orbit: { semi_major_axis_au: 1.0 }
calendar: { months: 12, month_length_days: 30 }
}
}"#;
WorldDefinition::from_hjson(body).unwrap()
}
#[test]
fn biome_cell_is_total_over_every_variant() {
for b in [
Biome::Ocean,
Biome::IceCap,
Biome::Tundra,
Biome::Taiga,
Biome::TemperateForest,
Biome::TemperateGrassland,
Biome::Mediterranean,
Biome::ColdDesert,
Biome::HotDesert,
Biome::Savanna,
Biome::TropicalSeasonal,
Biome::TropicalRainforest,
] {
let (ch, _) = biome_cell(b);
assert!(!ch.is_whitespace(), "{b:?} maps to whitespace");
}
}
#[test]
fn compose_fills_the_requested_dimensions() {
let layers = compile_layers(&terra());
let grid = compose(&layers, 40, 20);
assert_eq!(grid.len(), 20);
assert!(grid.iter().all(|r| r.len() == 40));
let sea = grid.iter().flatten().filter(|&&(c, _)| c == '~').count();
assert!(sea > 0, "expected some ocean cells in the downsampled map");
}
#[test]
fn lint_map_flags_a_town_in_the_ocean_but_not_on_land() {
let base = terra();
let layers = compile_layers(&base);
let (w, h) = (layers.climate.width, layers.climate.height);
let cell = |i: usize| (i % w, i / w);
let ocean = (0..w * h)
.find(|&i| layers.climate.biome[i] == Biome::Ocean)
.map(cell)
.expect("terra has ocean");
let land = (0..w * h)
.find(|&i| layers.climate.biome[i] != Biome::Ocean)
.map(cell)
.expect("terra has land");
let body = format!(
r#"{{
name: "Terra"
seed: 0x5151
astronomy: {{
star: {{ luminosity_solar: 1.0 }}
planet: {{ mass_earth: 1.0, radius_earth: 1.0, axial_tilt_deg: 23.4, day_length_hours: 24.0 }}
orbit: {{ semi_major_axis_au: 1.0 }}
calendar: {{ months: 12, month_length_days: 30 }}
}}
geography: {{ landmarks: [
{{ name: "Sunkport", kind: "city", x: {}, y: {} }}
{{ name: "Dryhold", kind: "city", x: {}, y: {} }}
] }}
}}"#,
ocean.0, ocean.1, land.0, land.1
);
let def = WorldDefinition::from_hjson(&body).unwrap();
let findings = lint_map(&def, &layers);
assert!(
findings.iter().any(|f| f.text.contains("Sunkport") && f.text.contains("ocean")),
"the ocean town should be flagged: {:?}",
findings.iter().map(|f| &f.text).collect::<Vec<_>>()
);
assert!(
!findings.iter().any(|f| f.text.contains("Dryhold")),
"the land town should not be flagged"
);
let sunk = findings.iter().find(|f| f.text.contains("Sunkport")).unwrap();
assert_eq!(sunk.at, Some(ocean));
}
#[test]
fn apply_brush_raises_the_centre_most_and_clamps() {
let (w, h) = (9, 9);
let mut hm = vec![0.5f32; w * h];
apply_brush(&mut hm, w, h, 4, 4, 2, 0.4);
assert!((hm[4 * w + 4] - 0.9).abs() < 1e-4, "centre = {}", hm[4 * w + 4]);
assert!(hm[4 * w + 5] > 0.5 && hm[4 * w + 5] < 0.9);
assert_eq!(hm[0], 0.5);
apply_brush(&mut hm, w, h, 4, 4, 2, 5.0);
assert_eq!(hm[4 * w + 4], 1.0);
apply_brush(&mut hm, w, h, 4, 4, 2, -5.0);
assert_eq!(hm[4 * w + 4], 0.0);
}
#[test]
fn line_cells_connects_endpoints() {
let l = line_cells((0, 0), (4, 0));
assert_eq!(l.first(), Some(&(0, 0)));
assert_eq!(l.last(), Some(&(4, 0)));
assert_eq!(l.len(), 5); let d = line_cells((0, 0), (3, 3));
assert_eq!(d.first(), Some(&(0, 0)));
assert_eq!(d.last(), Some(&(3, 3)));
assert_eq!(line_cells((2, 2), (2, 2)), vec![(2, 2), (2, 2)]);
}
#[test]
fn click_to_source_maps_inside_the_grid_and_rejects_outside() {
use ratatui::layout::Rect;
let inner = Rect { x: 2, y: 1, width: 40, height: 18 };
assert_eq!(click_to_source(inner, 2, 1, 96, 64), Some((0, 0)));
assert_eq!(click_to_source(inner, 0, 0, 96, 64), None);
assert_eq!(click_to_source(inner, 50, 1, 96, 64), None);
assert_eq!(click_to_source(inner, 5, 17, 96, 64), None); let (sx, sy) = click_to_source(inner, 22, 9, 96, 64).unwrap();
assert!(sx < 96 && sy < 64);
}
#[test]
fn source_to_display_maps_and_clamps() {
assert_eq!(source_to_display((0, 0), (96, 64), (48, 16)), (0, 0));
assert_eq!(source_to_display((95, 63), (96, 64), (48, 16)), (47, 15));
assert_eq!(source_to_display((48, 32), (96, 64), (48, 16)), (24, 8));
assert_eq!(source_to_display((5, 5), (0, 10), (10, 10)), (0, 0));
}
#[test]
fn compose_degrades_when_grid_is_smaller_than_source() {
let layers = compile_layers(&terra());
let grid = compose(&layers, 1, 1);
assert_eq!(grid.len(), 1);
assert_eq!(grid[0].len(), 1);
}
}