use crate::color::RgbColor;
use crate::interior_wall_layout::{interior_door_display_xy, interior_wall_glyph_line};
use flatland_client_lib::GameState;
use flatland_protocol::{BuildingView, InteriorMapView, TerrainKindView};
use crate::map_presentation::{self, MapPresentation};
pub struct WorldView {
pub width: usize,
pub height: usize,
pub cells: Vec<String>,
pub cell_fg: Vec<Option<RgbColor>>,
pub target_t1_cells: Vec<bool>,
pub target_t2_cells: Vec<bool>,
pub origin_x: f32,
pub origin_y: f32,
pub inside_building: Option<String>,
}
#[derive(Debug, Clone, Copy)]
pub struct WorldViewOptions {
pub paint_local_player: bool,
pub paint_overlays: bool,
}
impl Default for WorldViewOptions {
fn default() -> Self {
Self {
paint_local_player: true,
paint_overlays: true,
}
}
}
impl WorldViewOptions {
pub fn terrain_only() -> Self {
Self {
paint_local_player: false,
paint_overlays: false,
}
}
}
impl WorldView {
pub fn build_with_target(
state: &GameState,
view_w: usize,
view_h: usize,
map_target: Option<(f32, f32)>,
) -> Self {
Self::build_with_options(
state,
view_w,
view_h,
map_target,
WorldViewOptions::default(),
)
}
pub fn build_with_options(
state: &GameState,
view_w: usize,
view_h: usize,
map_target: Option<(f32, f32)>,
options: WorldViewOptions,
) -> Self {
let (px, py) = state.player_position();
Self::build_with_anchor(state, view_w, view_h, px, py, map_target, options)
}
pub fn build_with_anchor(
state: &GameState,
view_w: usize,
view_h: usize,
anchor_x: f32,
anchor_y: f32,
map_target: Option<(f32, f32)>,
options: WorldViewOptions,
) -> Self {
map_presentation::maybe_reload_for_content_rev(state.content_rev);
let px = anchor_x;
let py = anchor_y;
let inside_building = state.effective_inside_building();
let width = view_w.max(3);
let height = view_h.max(3);
let grass = map_presentation::terrain_for(TerrainKindView::Grass);
let mut cells = vec![grass.glyph.clone(); width * height];
let mut cell_fg = vec![Some(grass.color); width * height];
let half_w = (width / 2) as i32;
let half_h = (height / 2) as i32;
if let (Some(_bid), Some(interior)) =
(inside_building.as_deref(), state.interior_map.as_ref())
{
let player_z = state
.player
.as_ref()
.map(|p| p.transform.position.z)
.unwrap_or(0.0);
let active_floor = if interior.floor_height_m > f32::EPSILON {
(player_z / interior.floor_height_m).round() as i32
} else {
0
};
paint_interior_background(&mut cells, &mut cell_fg, width, height, interior);
paint_interior_rooms(
&mut cells,
&mut cell_fg,
width,
height,
interior,
px,
py,
half_w,
half_h,
active_floor,
);
let door_gaps = interior_door_gaps_snapped(interior, &state.doors, active_floor);
paint_interior_merged_walls(
&mut cells,
&mut cell_fg,
width,
height,
&interior.rooms,
active_floor,
px,
py,
half_w,
half_h,
&door_gaps,
);
} else {
paint_terrain(
&mut cells,
&mut cell_fg,
width,
height,
state,
px,
py,
half_w,
half_h,
);
for building in &state.buildings {
if building.tags.iter().any(|t| t == "well") {
paint_well(
&mut cells,
&mut cell_fg,
width,
height,
building,
px,
py,
half_w,
half_h,
);
} else {
paint_building_walls_centered(
&mut cells,
&mut cell_fg,
width,
height,
building,
px,
py,
half_w,
half_h,
);
}
}
}
if options.paint_overlays {
for door in &state.doors {
let (wx, wy) = match (inside_building.as_ref(), state.interior_map.as_ref()) {
(Some(_), Some(interior)) => {
let player_z = state
.player
.as_ref()
.map(|p| p.transform.position.z)
.unwrap_or(0.0);
let active_floor = if interior.floor_height_m > f32::EPSILON {
(player_z / interior.floor_height_m).round() as i32
} else {
0
};
interior_door_display_xy(interior, active_floor, &door.id, door.x, door.y)
}
_ => (door.x, door.y),
};
if let Some((gx, gy)) =
world_to_grid(wx, wy, px, py, half_w, half_h, width, height)
{
let idx = gy * width + gx;
paint_presentation(
&mut cells,
&mut cell_fg,
idx,
&map_presentation::door_presentation(door.open),
);
}
}
for npc in &state.npcs {
if let Some((gx, gy)) =
world_to_grid(npc.x, npc.y, px, py, half_w, half_h, width, height)
{
let idx = gy * width + gx;
paint_presentation(
&mut cells,
&mut cell_fg,
idx,
&map_presentation::npc_for(npc),
);
}
}
let ground = empty_ground_glyph();
let player_glyph = map_presentation::player_presentation().glyph;
for node in &state.resource_nodes {
if let Some((gx, gy)) =
world_to_grid(node.x, node.y, px, py, half_w, half_h, width, height)
{
let idx = gy * width + gx;
let pres = map_presentation::resource_for(node);
if can_paint_world_object(&cells[idx], &ground, &player_glyph) {
paint_presentation(&mut cells, &mut cell_fg, idx, &pres);
}
}
}
for drop in &state.ground_drops {
if let Some((gx, gy)) =
world_to_grid(drop.x, drop.y, px, py, half_w, half_h, width, height)
{
let idx = gy * width + gx;
if can_paint_world_object(&cells[idx], &ground, &player_glyph) {
paint_presentation(
&mut cells,
&mut cell_fg,
idx,
&map_presentation::loot_presentation(),
);
}
}
}
for chest in &state.placed_containers {
if let Some((gx, gy)) =
world_to_grid(chest.x, chest.y, px, py, half_w, half_h, width, height)
{
let idx = gy * width + gx;
if can_paint_world_object(&cells[idx], &ground, &player_glyph) {
paint_presentation(
&mut cells,
&mut cell_fg,
idx,
&map_presentation::chest_presentation(chest.locked),
);
}
}
}
if state.effective_inside_building().is_none() {
for inter in &state.interactables {
if inter.kind != "quest_board" {
continue;
}
if let Some((gx, gy)) =
world_to_grid(inter.x, inter.y, px, py, half_w, half_h, width, height)
{
let idx = gy * width + gx;
if can_paint_world_object(&cells[idx], &ground, &player_glyph) {
paint_presentation(
&mut cells,
&mut cell_fg,
idx,
&map_presentation::quest_board_presentation(),
);
}
}
}
}
for entity in &state.entities {
if !options.paint_local_player && entity.id == state.entity_id {
continue;
}
if entity.id != state.entity_id
&& entity.inside_building.as_deref() != inside_building.as_deref()
{
continue;
}
if let (Some(_), Some(interior)) =
(inside_building.as_deref(), state.interior_map.as_ref())
{
let player_z = state
.player
.as_ref()
.map(|p| p.transform.position.z)
.unwrap_or(0.0);
if interior.floor_height_m > f32::EPSILON {
let pf = (player_z / interior.floor_height_m).round() as i32;
let ef =
(entity.transform.position.z / interior.floor_height_m).round() as i32;
if pf != ef {
continue;
}
}
}
if let Some(pres) = entity_presentation(entity, state.entity_id) {
if let Some((gx, gy)) = world_to_grid(
entity.transform.position.x,
entity.transform.position.y,
px,
py,
half_w,
half_h,
width,
height,
) {
let idx = gy * width + gx;
paint_presentation(&mut cells, &mut cell_fg, idx, &pres);
}
}
}
}
if options.paint_local_player {
if state.player_entity().is_some() {
let cx = half_w as usize;
let cy = half_h as usize;
let idx = cy * width + cx;
if idx < cells.len() {
paint_presentation(
&mut cells,
&mut cell_fg,
idx,
&map_presentation::player_presentation(),
);
}
}
}
let mut target_t1_cells = vec![false; cells.len()];
let mut target_t2_cells = vec![false; cells.len()];
if options.paint_overlays {
for (slot, cells_out) in [(1, &mut target_t1_cells), (2, &mut target_t2_cells)] {
let target_id = state
.combat_slots
.iter()
.find(|s| s.slot_index == slot)
.and_then(|s| s.target_entity_id)
.or_else(|| if slot == 1 { state.combat_target } else { None });
let Some(target_id) = target_id else {
continue;
};
let (tx, ty) = if let Some(npc) = state
.npcs
.iter()
.find(|n| n.entity_id == Some(target_id))
{
(npc.x, npc.y)
} else if let Some(entity) = state.entities.iter().find(|e| e.id == target_id) {
(
entity.transform.position.x,
entity.transform.position.y,
)
} else {
continue;
};
if let Some((gx, gy)) =
world_to_grid(tx, ty, px, py, half_w, half_h, width, height)
{
let idx = gy * width + gx;
if idx < cells_out.len() {
cells_out[idx] = true;
}
}
}
}
if options.paint_overlays {
if let Some((tx, ty)) = map_target {
if let Some((gx, gy)) = world_to_grid(tx, ty, px, py, half_w, half_h, width, height)
{
let idx = gy * width + gx;
if idx < cells.len() {
cells[idx] = "X".into();
cell_fg[idx] = Some(RgbColor::YELLOW);
}
}
}
}
Self {
width,
height,
cells,
cell_fg,
target_t1_cells,
target_t2_cells,
origin_x: px,
origin_y: py,
inside_building,
}
}
}
fn paint_presentation(
cells: &mut [String],
cell_fg: &mut [Option<RgbColor>],
idx: usize,
pres: &MapPresentation,
) {
cells[idx] = pres.glyph.clone();
cell_fg[idx] = Some(pres.color);
}
fn empty_ground_glyph() -> String {
map_presentation::terrain_for(TerrainKindView::Grass).glyph
}
fn entity_presentation(
entity: &flatland_protocol::EntityState,
player_id: u64,
) -> Option<MapPresentation> {
if entity.id == player_id {
return Some(map_presentation::player_presentation());
}
if entity
.vitals
.as_ref()
.is_some_and(|v| v.life_state == flatland_protocol::LifeState::Dead)
{
return Some(map_presentation::corpse_presentation());
}
Some(map_presentation::entity_fallback(&entity.label))
}
fn paint_interior_background(
cells: &mut [String],
cell_fg: &mut [Option<RgbColor>],
_width: usize,
_height: usize,
interior: &InteriorMapView,
) {
let bg = crate::color::parse_color(&interior.background_color).unwrap_or(RgbColor::BLACK);
for idx in 0..cells.len() {
cells[idx] = " ".into();
cell_fg[idx] = Some(bg);
}
}
fn paint_interior_rooms(
cells: &mut [String],
cell_fg: &mut [Option<RgbColor>],
width: usize,
height: usize,
interior: &InteriorMapView,
px: f32,
py: f32,
half_w: i32,
half_h: i32,
active_floor: i32,
) {
let default_color = interior
.default_floor_color
.as_deref()
.and_then(crate::color::parse_color)
.unwrap_or(RgbColor::rgb(0x2a, 0x2a, 0x2a));
for room in &interior.rooms {
if room.floor != active_floor {
continue;
}
let floor_color = room
.floor_color
.as_deref()
.and_then(crate::color::parse_color)
.unwrap_or(default_color);
let glyph = room.floor_glyph.as_deref().unwrap_or(".").to_string();
let x0 = room.x0.floor() as i32;
let y0 = room.y0.floor() as i32;
let x1 = room.x1.ceil() as i32 - 1;
let y1 = room.y1.ceil() as i32 - 1;
for wy in y0..=y1 {
for wx in x0..=x1 {
if let Some((gx, gy)) =
world_to_grid(wx as f32, wy as f32, px, py, half_w, half_h, width, height)
{
let idx = gy * width + gx;
cells[idx] = glyph.clone();
cell_fg[idx] = Some(floor_color);
}
}
}
}
}
fn interior_door_gaps(
interior: &InteriorMapView,
doors: &[flatland_protocol::DoorView],
active_floor: i32,
) -> Vec<(f32, f32)> {
let room_floor = |id: &str| -> Option<i32> {
interior
.rooms
.iter()
.find(|r| r.id == id)
.map(|r| r.floor)
};
let mut gaps: Vec<(f32, f32)> = interior
.room_doors
.iter()
.filter_map(|d| {
let on_floor = room_floor(&d.room_a) == Some(active_floor)
|| room_floor(&d.room_b) == Some(active_floor);
if !on_floor {
return None;
}
doors
.iter()
.find(|door| door.id == d.id)
.filter(|door| door.open || d.kind == "stairs")
.map(|door| (door.x, door.y))
})
.collect();
for door in doors {
if door.portal.is_some() && door.open {
gaps.push((door.x, door.y));
}
}
gaps
}
fn interior_door_gaps_snapped(
interior: &flatland_protocol::InteriorMapView,
doors: &[flatland_protocol::DoorView],
floor: i32,
) -> Vec<(f32, f32)> {
let mut gaps = Vec::new();
for (x, y) in interior_door_gaps(interior, doors, floor) {
let door_id = doors
.iter()
.find(|d| (d.x - x).abs() < 0.05 && (d.y - y).abs() < 0.05)
.map(|d| d.id.as_str())
.unwrap_or("");
gaps.push(interior_door_display_xy(
interior,
floor,
door_id,
x,
y,
));
}
gaps
}
fn near_door_gap(wx: i32, wy: i32, door_gaps: &[(f32, f32)]) -> bool {
door_gaps
.iter()
.any(|(dx, dy)| (wx as f32 - dx).abs() < 1.0 && (wy as f32 - dy).abs() < 1.0)
}
const INTERIOR_WALL_EDGE_TOL: f32 = 0.6;
fn interior_wall_grid_line(fixed: f32) -> i32 {
interior_wall_glyph_line(fixed)
}
fn quant_interior_wall_coord(v: f32) -> i64 {
(v * 1000.0).round() as i64
}
fn merge_interior_wall_intervals(mut intervals: Vec<(f32, f32)>) -> Vec<(f32, f32)> {
if intervals.is_empty() {
return intervals;
}
intervals.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
let mut out = vec![intervals[0]];
for &(a, b) in intervals.iter().skip(1) {
let last_idx = out.len() - 1;
if a <= out[last_idx].1 + INTERIOR_WALL_EDGE_TOL {
out[last_idx].1 = out[last_idx].1.max(b);
} else {
out.push((a, b));
}
}
out
}
fn paint_interior_merged_walls(
cells: &mut [String],
cell_fg: &mut [Option<RgbColor>],
width: usize,
height: usize,
rooms: &[flatland_protocol::InteriorRoomView],
floor: i32,
px: f32,
py: f32,
half_w: i32,
half_h: i32,
door_gaps: &[(f32, f32)],
) {
use std::collections::HashMap;
let mut horiz: HashMap<i64, Vec<(f32, f32)>> = HashMap::new();
let mut vert: HashMap<i64, Vec<(f32, f32)>> = HashMap::new();
for room in rooms.iter().filter(|r| r.floor == floor) {
let west = room.x0.min(room.x1);
let east = room.x0.max(room.x1);
let south = room.y0.min(room.y1);
let north = room.y0.max(room.y1);
if east - west >= 0.25 {
horiz.entry(quant_interior_wall_coord(south))
.or_default()
.push((west, east));
horiz.entry(quant_interior_wall_coord(north))
.or_default()
.push((west, east));
}
if north - south >= 0.25 {
vert.entry(quant_interior_wall_coord(west))
.or_default()
.push((south, north));
vert.entry(quant_interior_wall_coord(east))
.or_default()
.push((south, north));
}
}
for (key, intervals) in horiz {
let fixed = key as f32 / 1000.0;
let merged = merge_interior_wall_intervals(intervals);
let wy = interior_wall_grid_line(fixed);
for (start, end) in merged {
if end - start < 0.2 {
continue;
}
let x0 = start.round() as i32;
let x1 = end.round() as i32;
for wx in x0..=x1 {
if !near_door_gap(wx, wy, door_gaps) {
paint_wall_cell(
cells,
cell_fg,
width,
height,
wx,
wy,
px,
py,
half_w,
half_h,
"-",
);
}
}
}
}
for (key, intervals) in vert {
let fixed = key as f32 / 1000.0;
let merged = merge_interior_wall_intervals(intervals);
let wx = interior_wall_grid_line(fixed);
for (start, end) in merged {
if end - start < 0.2 {
continue;
}
let y0 = start.round() as i32;
let y1 = end.round() as i32;
for wy in y0..=y1 {
if !near_door_gap(wx, wy, door_gaps) {
paint_wall_cell(
cells,
cell_fg,
width,
height,
wx,
wy,
px,
py,
half_w,
half_h,
"|",
);
}
}
}
}
}
fn paint_terrain(
cells: &mut [String],
cell_fg: &mut [Option<RgbColor>],
width: usize,
height: usize,
state: &GameState,
px: f32,
py: f32,
_half_w: i32,
_half_h: i32,
) {
for gy in 0..height {
for gx in 0..width {
let Some((wx, wy)) = grid_to_world(gx, gy, px, py, width, height) else {
continue;
};
let zone = state.terrain_zone_at(wx, wy);
let kind = zone.map(|z| z.kind).unwrap_or(TerrainKindView::Grass);
let elev = zone.map(|z| z.elevation).unwrap_or(0.0);
let style = map_presentation::terrain_for_zone(
kind,
elev,
zone.and_then(|z| z.glyph.as_deref()),
zone.and_then(|z| z.color.as_deref()),
);
let idx = gy * width + gx;
paint_presentation(cells, cell_fg, idx, &style);
}
}
}
fn paint_well(
cells: &mut [String],
cell_fg: &mut [Option<RgbColor>],
width: usize,
height: usize,
building: &BuildingView,
px: f32,
py: f32,
half_w: i32,
half_h: i32,
) {
let hw = building.width_m / 2.0;
let hd = building.depth_m / 2.0;
let x0 = (building.x - hw).floor() as i32;
let y0 = (building.y - hd).floor() as i32;
let x1 = (building.x + hw).ceil() as i32 - 1;
let y1 = (building.y + hd).ceil() as i32 - 1;
let water = map_presentation::shallow_water_presentation();
let center = map_presentation::well_center_presentation();
for wy in y0..=y1 {
for wx in x0..=x1 {
let pres = if wx == building.x.round() as i32 && wy == building.y.round() as i32 {
center.clone()
} else {
water.clone()
};
if let Some((gx, gy)) =
world_to_grid(wx as f32, wy as f32, px, py, half_w, half_h, width, height)
{
let idx = gy * width + gx;
if !is_wall(&cells[idx]) {
paint_presentation(cells, cell_fg, idx, &pres);
}
}
}
}
}
fn paint_building_walls_centered(
cells: &mut [String],
cell_fg: &mut [Option<RgbColor>],
width: usize,
height: usize,
building: &BuildingView,
px: f32,
py: f32,
half_w: i32,
half_h: i32,
) {
let hw = building.width_m / 2.0;
let hd = building.depth_m / 2.0;
paint_building_walls(
cells,
cell_fg,
width,
height,
building.x - hw,
building.y - hd,
building.width_m,
building.depth_m,
px,
py,
half_w,
half_h,
&[],
);
}
fn paint_building_walls(
cells: &mut [String],
cell_fg: &mut [Option<RgbColor>],
width: usize,
height: usize,
origin_x: f32,
origin_y: f32,
width_m: f32,
depth_m: f32,
px: f32,
py: f32,
half_w: i32,
half_h: i32,
door_gaps: &[(f32, f32)],
) {
let x0 = origin_x.floor() as i32;
let y0 = origin_y.floor() as i32;
let x1 = (origin_x + width_m).ceil() as i32 - 1;
let y1 = (origin_y + depth_m).ceil() as i32 - 1;
if x1 < x0 || y1 < y0 {
return;
}
for wx in x0..=x1 {
if !near_door_gap(wx, y0, door_gaps) {
paint_wall_cell(
cells, cell_fg, width, height, wx, y0, px, py, half_w, half_h, "-",
);
}
if !near_door_gap(wx, y1, door_gaps) {
paint_wall_cell(
cells, cell_fg, width, height, wx, y1, px, py, half_w, half_h, "-",
);
}
}
for wy in y0 + 1..y1 {
if !near_door_gap(x0, wy, door_gaps) {
paint_wall_cell(
cells, cell_fg, width, height, x0, wy, px, py, half_w, half_h, "|",
);
}
if !near_door_gap(x1, wy, door_gaps) {
paint_wall_cell(
cells, cell_fg, width, height, x1, wy, px, py, half_w, half_h, "|",
);
}
}
if !near_door_gap(x0, y0, door_gaps) {
paint_wall_cell(
cells, cell_fg, width, height, x0, y0, px, py, half_w, half_h, "+",
);
}
if !near_door_gap(x1, y0, door_gaps) {
paint_wall_cell(
cells, cell_fg, width, height, x1, y0, px, py, half_w, half_h, "+",
);
}
if !near_door_gap(x0, y1, door_gaps) {
paint_wall_cell(
cells, cell_fg, width, height, x0, y1, px, py, half_w, half_h, "+",
);
}
if !near_door_gap(x1, y1, door_gaps) {
paint_wall_cell(
cells, cell_fg, width, height, x1, y1, px, py, half_w, half_h, "+",
);
}
}
fn paint_wall_cell(
cells: &mut [String],
cell_fg: &mut [Option<RgbColor>],
width: usize,
height: usize,
wx: i32,
wy: i32,
px: f32,
py: f32,
half_w: i32,
half_h: i32,
ch: &str,
) {
let Some((gx, gy)) = world_to_grid(wx as f32, wy as f32, px, py, half_w, half_h, width, height)
else {
return;
};
let idx = gy * width + gx;
let ground = empty_ground_glyph();
if cells[idx] == ground || cells[idx] == " " || is_wall(&cells[idx]) {
cells[idx] = merge_wall_corner(&cells[idx], ch, &ground);
cell_fg[idx] = Some(map_presentation::wall_presentation().color);
}
}
fn is_wall(glyph: &str) -> bool {
matches!(glyph.chars().next(), Some('+' | '-' | '|'))
}
fn can_paint_world_object(glyph: &str, _ground: &str, _player_glyph: &str) -> bool {
!is_wall(glyph)
}
fn merge_wall_corner(existing: &str, incoming: &str, ground: &str) -> String {
if existing == ground || existing == " " {
return incoming.to_string();
}
if existing == incoming {
return existing.to_string();
}
"+".to_string()
}
fn world_to_grid(
x: f32,
y: f32,
px: f32,
py: f32,
half_w: i32,
half_h: i32,
width: usize,
height: usize,
) -> Option<(usize, usize)> {
let dx = (x - px).round() as i32;
let dy = (y - py).round() as i32;
if dx.abs() > half_w || dy.abs() > half_h {
return None;
}
let gx = half_w + dx;
let gy = half_h - dy;
if gx < 0 || gy < 0 {
return None;
}
let gx = gx as usize;
let gy = gy as usize;
if gx >= width || gy >= height {
return None;
}
Some((gx, gy))
}
pub fn grid_to_world(
gx: usize,
gy: usize,
px: f32,
py: f32,
view_w: usize,
view_h: usize,
) -> Option<(f32, f32)> {
if gx >= view_w || gy >= view_h {
return None;
}
let half_w = (view_w / 2) as i32;
let half_h = (view_h / 2) as i32;
let dx = gx as i32 - half_w;
let dy = half_h - gy as i32;
Some((px + dx as f32, py + dy as f32))
}
#[cfg(test)]
mod tests {
use super::*;
use flatland_protocol::{
BuildingView, EntityState, PlayerVitals, PrimaryAttributes, Transform, WorldCoord,
};
fn state_with_building(building: BuildingView) -> GameState {
GameState {
session_id: 1,
entity_id: 1,
character_id: None,
tick: 0,
chunk_rev: 0,
content_rev: 0,
publish_rev: 0,
entities: vec![EntityState {
id: 1,
label: "You".into(),
transform: Transform {
position: WorldCoord::surface(148.0, 118.0),
yaw: 0.0,
velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
},
vitals: Some(PlayerVitals::default()),
attributes: Some(PrimaryAttributes::default()),
skills: Some(flatland_protocol::PlayerSkills::default()),
inside_building: None,
tile_id: None,
paperdoll_ref: None,
presentation_state: None,
sprite_mode: None,
progression_xp: None,
combat_cues: Vec::new(),
}],
player: None,
resource_nodes: vec![],
ground_drops: vec![],
placed_containers: vec![],
buildings: vec![building],
doors: vec![],
interior_map: None,
npcs: vec![],
blueprints: vec![],
world_x0: 0.0,
world_y0: 0.0,
world_width_m: 256.0,
world_height_m: 256.0,
terrain_zones: vec![],
z_platforms: vec![],
z_transitions: vec![],
z_bands_outdoor_backup: None,
world_clock: flatland_protocol::WorldClock::default(),
inventory: Default::default(),
inventory_hints: Default::default(),
logs: Default::default(),
intents_sent: 0,
ticks_received: 0,
connected: true,
disconnect_reason: None,
show_stats: false,
hud_log_hidden: false,
show_equip_menu: false,
equip_menu_index: 0,
ledger: None,
career: None,
character_sheet_tab: flatland_client_lib::CharacterSheetTab::Character,
ledger_period: flatland_client_lib::LedgerPeriod::Day,
show_craft_menu: false,
craft_menu_index: 0,
craft_batch_quantity: 1,
show_shop_menu: false,
shop_catalog: None,
bank_panel: None,
bank_menu_index: 0,
bank_ui_mode: flatland_client_lib::BankUiMode::Menu,
storage_panel: None,
market_panel: None,
market_menu_index: 0,
market_filter: String::new(),
market_filter_focused: false,
market_category_filter: None,
market_buy_confirm: None,
market_ui_mode: flatland_client_lib::MarketUiMode::Browse,
storage_menu_index: 0,
storage_ui_mode: flatland_client_lib::StorageUiMode::Menu,
property_zones: vec![],
tax_zones: vec![],
growth_zones: vec![],
biome_zones: vec![],
property_plots: vec![],
property_plot_settings: None,
claim_mode: None,
relocate_mode: None,
sell_plot_confirm: None,
sell_plot_armed_at: None,
show_plant_menu: false,
plant_menu_index: 0,
show_farm_access: false,
farm_access_name_draft: String::new(),
farm_access_discount_bps: 0,
farm_access_index: 0,
plant_quantity: 1,
shop_tab: flatland_client_lib::ShopTab::default(),
shop_menu_index: 0,
shop_quantity: 1,
shop_trade_log: std::collections::VecDeque::new(),
show_npc_verb_menu: false,
npc_verb_target: None,
npc_verb_index: 0,
player_verbs: Default::default(),
social_chat: Default::default(),
trade_ui: Default::default(),
whisper_pouch_ui: Default::default(),
show_npc_chat: false,
npc_chat: None,
show_inventory_menu: false,
inventory_menu_index: 0,
inventory_tab: flatland_client_lib::InventoryTab::OnPerson,
inventory_filter: String::new(),
inventory_filter_focused: false,
show_move_picker: false,
show_rename_prompt: false,
show_worker_rename: false,
rename_buffer: String::new(),
move_picker_index: 0,
move_picker: None,
show_grant_picker: false,
grant_picker_index: 0,
grant_picker: None,
show_destroy_picker: false,
destroy_confirm_pending: false,
destroy_picker: None,
combat_target: None,
combat_target_label: None,
ground_target: None,
combat_fx: Vec::new(),
in_combat: false,
auto_attack: true,
combat_has_los: false,
attack_cd_ticks: 0,
gcd_ticks: 0,
weapon_ability_id: "unarmed".into(),
mainhand_template_id: None,
mainhand_label: None,
offhand_template_id: None,
offhand_label: None,
mainhand_hand_slots: 1,
defense: None,
worn: std::collections::BTreeMap::new(),
carry_mass: 0.0,
carry_mass_max: 0.0,
encumbrance: flatland_protocol::EncumbranceState::Light,
inventory_stacks: Vec::new(),
keychain_stacks: Vec::new(),
whisper_pouch_stacks: Vec::new(),
combat_target_detail: None,
statuses: Vec::new(),
cast_progress: None,
timed_channel: None,
ability_cooldowns: Vec::new(),
blocking_active: false,
max_target_slots: 1,
combat_slots: Vec::new(),
rotation_presets: Vec::new(),
known_abilities: Vec::new(),
ability_meta: std::collections::HashMap::new(),
hotbar: vec![None; 9],
max_abilities_per_rotation: 0,
show_loadout_menu: false,
show_keychain_menu: false,
keychain_menu_index: 0,
show_rotation_editor: false,
loadout_menu_index: 0,
loadout_hotbar_slot: 1,
loadout_ability_index: 0,
loadout_focus_presets: false,
rotation_editor: Default::default(),
harvest_in_progress: false,
harvest_started_at: None,
pending_craft_ack: None,
pending_worker_job_ack: None,
attending_worker_instance_id: None,
quest_log: Vec::new(),
interactables: Vec::new(),
show_quest_offer: false,
pending_quest_offer: None,
show_quest_menu: false,
quest_menu_index: 0,
quest_withdraw_confirm: false,
hired_workers: Vec::new(),
show_workers_menu: false,
workers_menu_index: 0,
workers_menu_compact: false,
worker_step_display: std::collections::BTreeMap::new(),
worker_error_display: std::collections::BTreeMap::new(),
show_worker_give_picker: false,
worker_give_picker_index: 0,
worker_give_picker: None,
show_worker_give_target_picker: false,
worker_give_target_picker_index: 0,
worker_give_target_picker: None,
show_worker_take_picker: false,
worker_take_picker_index: 0,
worker_take_picker: None,
show_worker_teach_picker: false,
worker_teach_picker_index: 0,
worker_teach_picker: None,
worker_route_editor: None,
progression_curve: None,
}
}
#[test]
fn shallow_water_terrain_paints_tilde() {
use flatland_protocol::TerrainZoneView;
let mut state = state_with_building(BuildingView {
id: "x".into(),
label: "X".into(),
x: 128.0,
y: 128.0,
width_m: 1.0,
depth_m: 1.0,
interior_blueprint: None,
tags: vec![],
market_boundary_zone_ids: vec![],
market_max_volume: None,
wall_set: None,
roof_set: None,
});
state.terrain_zones.push(TerrainZoneView {
id: "pond".into(),
x0: 126.0,
y0: 126.0,
x1: 130.0,
y1: 130.0,
kind: TerrainKindView::ShallowWater,
elevation: -0.5,
glyph: None,
color: None,
tile_id: None,
z_order: 0,
channel_start_tick: None,
channel_end_tick: None,
});
state.entities = vec![EntityState {
id: 1,
label: "You".into(),
transform: Transform {
position: WorldCoord::surface(128.0, 128.0),
yaw: 0.0,
velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
},
vitals: Some(PlayerVitals::default()),
attributes: Some(PrimaryAttributes::default()),
skills: Some(flatland_protocol::PlayerSkills::default()),
inside_building: None,
tile_id: None,
paperdoll_ref: None,
presentation_state: None,
sprite_mode: None,
progression_xp: None,
combat_cues: Vec::new(),
}];
state.player = state.entities.first().cloned();
let view = WorldView::build_with_target(&state, 9, 9, None);
let flat = view.cells.join("");
let water = map_presentation::shallow_water_presentation();
assert!(
flat.contains(&water.glyph),
"expected water tiles ({:?}): {flat}",
water.glyph
);
assert!(
view.cell_fg.iter().any(|c| *c == Some(water.color)),
"expected water color on terrain cells"
);
}
#[test]
fn zone_glyph_and_color_overrides_paint_on_map() {
use flatland_protocol::TerrainZoneView;
let mut state = state_with_building(BuildingView {
id: "x".into(),
label: "X".into(),
x: 128.0,
y: 128.0,
width_m: 1.0,
depth_m: 1.0,
interior_blueprint: None,
tags: vec![],
market_boundary_zone_ids: vec![],
market_max_volume: None,
wall_set: None,
roof_set: None,
});
state.terrain_zones.push(TerrainZoneView {
id: "marked".into(),
x0: 126.0,
y0: 126.0,
x1: 130.0,
y1: 130.0,
kind: TerrainKindView::Grass,
elevation: 0.0,
glyph: Some("%".into()),
color: Some("magenta".into()),
tile_id: None,
z_order: 0,
channel_start_tick: None,
channel_end_tick: None,
});
state.entities = vec![EntityState {
id: 1,
label: "You".into(),
transform: Transform {
position: WorldCoord::surface(128.0, 128.0),
yaw: 0.0,
velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
},
vitals: Some(PlayerVitals::default()),
attributes: Some(PrimaryAttributes::default()),
skills: Some(flatland_protocol::PlayerSkills::default()),
inside_building: None,
tile_id: None,
paperdoll_ref: None,
presentation_state: None,
sprite_mode: None,
progression_xp: None,
combat_cues: Vec::new(),
}];
state.player = state.entities.first().cloned();
let view = WorldView::build_with_target(&state, 9, 9, None);
let flat = view.cells.join("");
assert!(
flat.contains('%'),
"expected custom zone glyph on map: {flat}"
);
assert!(
view.cell_fg.iter().any(|c| *c == Some(RgbColor::MAGENTA)),
"expected custom zone color on terrain cells"
);
}
#[test]
fn well_paints_water_ring_and_center() {
let building = BuildingView {
id: "town_well".into(),
label: "Well".into(),
x: 122.0,
y: 106.0,
width_m: 3.0,
depth_m: 3.0,
interior_blueprint: None,
tags: vec!["well".into()],
market_boundary_zone_ids: vec![],
market_max_volume: None,
wall_set: None,
roof_set: None,
};
let mut state = state_with_building(building);
state.entities = vec![EntityState {
id: 1,
label: "You".into(),
transform: Transform {
position: WorldCoord::surface(120.0, 106.0),
yaw: 0.0,
velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
},
vitals: Some(PlayerVitals::default()),
attributes: Some(PrimaryAttributes::default()),
skills: Some(flatland_protocol::PlayerSkills::default()),
inside_building: None,
tile_id: None,
paperdoll_ref: None,
presentation_state: None,
sprite_mode: None,
progression_xp: None,
combat_cues: Vec::new(),
}];
state.player = state.entities.first().cloned();
let view = WorldView::build_with_target(&state, 9, 9, None);
let flat = view.cells.join("");
assert!(flat.contains('~'), "expected well water: {flat}");
assert!(flat.contains('O'), "expected well center: {flat}");
}
#[test]
fn broker_hut_draws_wall_outline() {
let building = BuildingView {
id: "broker_hut".into(),
label: "Broker's Hut".into(),
x: 148.0,
y: 118.0,
width_m: 8.0,
depth_m: 6.0,
interior_blueprint: None,
tags: vec![],
market_boundary_zone_ids: vec![],
market_max_volume: None,
wall_set: None,
roof_set: None,
};
let mut state = state_with_building(building);
state.player = state.entities.first().cloned();
let view = WorldView::build_with_target(&state, 25, 15, None);
let flat = view.cells.join("");
assert!(flat.contains('+'), "expected corners: {flat}");
assert!(flat.contains('-'), "expected horiz walls: {flat}");
assert!(flat.contains('|'), "expected vert walls: {flat}");
}
#[test]
fn interior_map_renders_rooms_and_walls() {
use flatland_protocol::{InteriorMapView, InteriorRoomView};
let building = BuildingView {
id: "broker_hut".into(),
label: "Broker's Hut".into(),
x: 148.0,
y: 118.0,
width_m: 8.0,
depth_m: 6.0,
interior_blueprint: Some("broker_hut".into()),
tags: vec![],
market_boundary_zone_ids: vec![],
market_max_volume: None,
wall_set: None,
roof_set: None,
};
let mut state = state_with_building(building);
state.interior_map = Some(InteriorMapView {
building_id: "broker_hut".into(),
blueprint_id: "broker_hut".into(),
background_color: "#000000".into(),
default_floor_color: Some("#2a2a2a".into()),
floor_height_m: 3.0,
z_platforms: vec![],
z_transitions: vec![],
rooms: vec![InteriorRoomView {
id: "main".into(),
label: "Main".into(),
floor: 0,
x0: 0.0,
y0: 0.0,
x1: 8.5,
y1: 7.0,
floor_color: None,
floor_glyph: None,
}],
room_doors: vec![],
});
state.entities = vec![EntityState {
id: 1,
label: "You".into(),
transform: Transform {
position: WorldCoord::surface(4.0, 3.0),
yaw: 0.0,
velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
},
vitals: Some(PlayerVitals::default()),
attributes: Some(PrimaryAttributes::default()),
skills: Some(flatland_protocol::PlayerSkills::default()),
inside_building: Some("broker_hut".into()),
tile_id: None,
paperdoll_ref: None,
presentation_state: None,
sprite_mode: None,
progression_xp: None,
combat_cues: Vec::new(),
}];
state.player = state.entities.first().cloned();
let view = WorldView::build_with_target(&state, 25, 15, None);
assert_eq!(view.inside_building.as_deref(), Some("broker_hut"));
let flat = view.cells.join("");
assert!(flat.contains('+'), "expected interior walls: {flat}");
assert!(flat.contains('@'), "expected player marker: {flat}");
}
#[test]
fn stale_interior_map_renders_outdoor_when_outside() {
use flatland_protocol::{InteriorMapView, InteriorRoomView};
let building = BuildingView {
id: "town_hall".into(),
label: "Town Hall".into(),
x: 163.0,
y: 137.0,
width_m: 20.0,
depth_m: 10.0,
interior_blueprint: Some("town_hall".into()),
tags: vec![],
market_boundary_zone_ids: vec![],
market_max_volume: None,
wall_set: None,
roof_set: None,
};
let mut state = state_with_building(building);
state.interior_map = Some(InteriorMapView {
building_id: "town_hall".into(),
blueprint_id: "town_hall".into(),
background_color: "#000000".into(),
default_floor_color: Some("#2a2a2a".into()),
floor_height_m: 3.0,
z_platforms: vec![],
z_transitions: vec![],
rooms: vec![InteriorRoomView {
id: "main_hall".into(),
label: "Main".into(),
floor: 0,
x0: -3.5,
y0: -8.0,
x1: 18.5,
y1: 6.0,
floor_color: None,
floor_glyph: None,
}],
room_doors: vec![],
});
state.player = state.entities.first().cloned();
let view = WorldView::build_with_target(&state, 25, 15, None);
assert!(view.inside_building.is_none());
let flat = view.cells.join("");
let grass = map_presentation::terrain_for(TerrainKindView::Grass).glyph;
assert!(
flat.contains(&grass),
"expected outdoor terrain, not stale interior background: {flat}"
);
assert!(
!flat.chars().all(|c| c == ' ' || c == '@'),
"stale interior_map must not paint black interior when outside"
);
}
#[test]
fn stale_inside_flag_still_renders_outdoor_world() {
use flatland_protocol::ResourceNodeState;
let building = BuildingView {
id: "broker_hut".into(),
label: "Broker's Hut".into(),
x: 148.0,
y: 118.0,
width_m: 8.0,
depth_m: 6.0,
interior_blueprint: None,
tags: vec![],
market_boundary_zone_ids: vec![],
market_max_volume: None,
wall_set: None,
roof_set: None,
};
let mut state = state_with_building(building);
state.entities = vec![EntityState {
id: 1,
label: "You".into(),
transform: Transform {
position: WorldCoord::surface(128.0, 128.0),
yaw: 0.0,
velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
},
vitals: Some(PlayerVitals::default()),
attributes: Some(PrimaryAttributes::default()),
skills: Some(flatland_protocol::PlayerSkills::default()),
inside_building: Some("broker_hut".into()),
tile_id: None,
paperdoll_ref: None,
presentation_state: None,
sprite_mode: None,
progression_xp: None,
combat_cues: Vec::new(),
}];
state.player = state.entities.first().cloned();
state
.resource_nodes
.push(flatland_protocol::ResourceNodeView {
id: "oak".into(),
label: "Oak".into(),
x: 126.0,
y: 134.0,
z: 0.0,
item_template: "oak_log".into(),
state: ResourceNodeState::Available,
blocking: true,
blocking_radius_m: 0.8,
tile_id: None,
yaw: 0.0,
pitch: 0.0,
roll: 0.0,
draw_scale: 1.0,
sprite_mode: None,
growth_progress: None,
presentation_state: None,
channel_start_tick: None,
channel_end_tick: None,
harvest_drop_templates: Vec::new(),});
let view = WorldView::build_with_target(&state, 25, 15, None);
assert_eq!(
view.inside_building.as_deref(),
Some("broker_hut"),
"server inside flag is authoritative"
);
let flat = view.cells.join("");
let oak = map_presentation::resource_for(&flatland_protocol::ResourceNodeView {
id: "oak".into(),
label: "Oak".into(),
x: 0.0,
y: 0.0,
z: 0.0,
item_template: "oak_log".into(),
state: flatland_protocol::ResourceNodeState::Available,
blocking: true,
blocking_radius_m: 0.8,
tile_id: None,
yaw: 0.0,
pitch: 0.0,
roll: 0.0,
draw_scale: 1.0,
sprite_mode: None,
growth_progress: None,
presentation_state: None,
channel_start_tick: None,
channel_end_tick: None,
harvest_drop_templates: Vec::new(),});
assert!(
flat.contains(&oak.glyph),
"expected nearby tree ({:?}): {flat}",
oak.glyph
);
assert!(flat.contains('@'), "expected player: {flat}");
}
#[test]
fn inside_building_flag_selects_active_instance() {
let town = BuildingView {
id: "town_hall".into(),
label: "Town Hall".into(),
x: 160.0,
y: 136.0,
width_m: 20.0,
depth_m: 10.0,
interior_blueprint: Some("town_hall".into()),
tags: vec![],
market_boundary_zone_ids: vec![],
market_max_volume: None,
wall_set: None,
roof_set: None,
};
let guild = BuildingView {
id: "guild_hall".into(),
label: "Guild Hall".into(),
x: 164.0,
y: 152.0,
width_m: 20.0,
depth_m: 14.0,
interior_blueprint: Some("guild_hall".into()),
tags: vec![],
market_boundary_zone_ids: vec![],
market_max_volume: None,
wall_set: None,
roof_set: None,
};
let mut state = state_with_building(town);
state.buildings.push(guild);
state.entities = vec![EntityState {
id: 1,
label: "You".into(),
transform: Transform {
position: WorldCoord::surface(4.0, 3.0),
yaw: 0.0,
velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
},
vitals: Some(PlayerVitals::default()),
attributes: Some(PrimaryAttributes::default()),
skills: Some(flatland_protocol::PlayerSkills::default()),
inside_building: Some("guild_hall".into()),
tile_id: None,
paperdoll_ref: None,
presentation_state: None,
sprite_mode: None,
progression_xp: None,
combat_cues: Vec::new(),
}];
state.player = state.entities.first().cloned();
state.world_width_m = 256.0;
state.world_height_m = 256.0;
let view = WorldView::build_with_target(&state, 25, 15, None);
assert_eq!(view.inside_building.as_deref(), Some("guild_hall"));
}
#[test]
fn combat_target_marks_creature_cell() {
let building = BuildingView {
id: "x".into(),
label: "X".into(),
x: 128.0,
y: 128.0,
width_m: 1.0,
depth_m: 1.0,
interior_blueprint: None,
tags: vec![],
market_boundary_zone_ids: vec![],
market_max_volume: None,
wall_set: None,
roof_set: None,
};
let mut state = state_with_building(building);
state.entities = vec![
EntityState {
id: 1,
label: "You".into(),
transform: Transform {
position: WorldCoord::surface(100.0, 100.0),
yaw: 0.0,
velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
},
vitals: Some(PlayerVitals::default()),
attributes: Some(PrimaryAttributes::default()),
skills: Some(flatland_protocol::PlayerSkills::default()),
inside_building: None,
tile_id: None,
paperdoll_ref: None,
presentation_state: None,
sprite_mode: None,
progression_xp: None,
combat_cues: Vec::new(),
},
EntityState {
id: 42,
label: "Rabbit".into(),
transform: Transform {
position: WorldCoord::surface(103.0, 100.0),
yaw: 0.0,
velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
},
vitals: None,
attributes: None,
skills: None,
inside_building: None,
tile_id: None,
paperdoll_ref: None,
presentation_state: None,
sprite_mode: None,
progression_xp: None,
combat_cues: Vec::new(),
},
];
state.player = state.entities.first().cloned();
state.combat_target = Some(42);
state.combat_target_label = Some("Rabbit".into());
let view = WorldView::build_with_target(&state, 25, 15, None);
let marked: usize = view
.target_t1_cells
.iter()
.chain(view.target_t2_cells.iter())
.filter(|b| **b)
.count();
assert_eq!(marked, 1, "exactly one targeted cell");
let idx = view
.target_t1_cells
.iter()
.chain(view.target_t2_cells.iter())
.position(|b| *b)
.expect("target cell");
assert_eq!(view.cells[idx], "R");
}
#[test]
fn combat_target_ring_prefers_live_npc_coords() {
let building = BuildingView {
id: "x".into(),
label: "X".into(),
x: 128.0,
y: 128.0,
width_m: 1.0,
depth_m: 1.0,
interior_blueprint: None,
tags: vec![],
market_boundary_zone_ids: vec![],
market_max_volume: None,
wall_set: None,
roof_set: None,
};
let mut state = state_with_building(building);
state.entities = vec![
EntityState {
id: 1,
label: "You".into(),
transform: Transform {
position: WorldCoord::surface(100.0, 100.0),
yaw: 0.0,
velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
},
vitals: Some(PlayerVitals::default()),
attributes: Some(PrimaryAttributes::default()),
skills: Some(flatland_protocol::PlayerSkills::default()),
inside_building: None,
tile_id: None,
paperdoll_ref: None,
presentation_state: None,
sprite_mode: None,
progression_xp: None,
combat_cues: Vec::new(),
},
EntityState {
id: 42,
label: "Rabbit".into(),
transform: Transform {
position: WorldCoord::surface(90.0, 100.0),
yaw: 0.0,
velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
},
vitals: None,
attributes: None,
skills: None,
inside_building: None,
tile_id: None,
paperdoll_ref: None,
presentation_state: None,
sprite_mode: None,
progression_xp: None,
combat_cues: Vec::new(),
},
];
state.npcs = vec![flatland_protocol::NpcView {
id: "rabbit-1".into(),
label: "Rabbit".into(),
role: "wildlife".into(),
x: 103.0,
y: 100.0,
building_id: None,
entity_id: Some(42),
life_state: Some(flatland_protocol::LifeState::Alive),
hp_pct: Some(1.0),
can_trade: false,
tile_id: None,
behavior_state: None,
presentation_state: None,
sprite_mode: None,
paperdoll_ref: None,
}];
state.player = state.entities.first().cloned();
state.combat_target = Some(42);
let view = WorldView::build_with_target(&state, 25, 15, None);
let marked = view
.target_t1_cells
.iter()
.position(|b| *b)
.expect("target cell");
let half_w = (view.width / 2) as i32;
let half_h = (view.height / 2) as i32;
let (live_gx, live_gy) =
world_to_grid(103.0, 100.0, 100.0, 100.0, half_w, half_h, view.width, view.height)
.expect("live npc in view");
let (stale_gx, stale_gy) =
world_to_grid(90.0, 100.0, 100.0, 100.0, half_w, half_h, view.width, view.height)
.expect("stale entity in view");
let live_idx = live_gy * view.width + live_gx;
let stale_idx = stale_gy * view.width + stale_gx;
assert_eq!(marked, live_idx, "ring must follow live NPC coords");
assert_ne!(marked, stale_idx, "ring must not stay on stale entity coords");
}
#[test]
fn grid_to_world_roundtrips_center() {
let px = 10.0;
let py = 20.0;
let view_w = 11;
let view_h = 11;
let half_w = (view_w / 2) as i32;
let half_h = (view_h / 2) as i32;
let (gx, gy) =
world_to_grid(12.0, 18.0, px, py, half_w, half_h, view_w, view_h).expect("in view");
let (wx, wy) = grid_to_world(gx, gy, px, py, view_w, view_h).expect("inverse");
assert!((wx - 12.0).abs() < 0.01);
assert!((wy - 18.0).abs() < 0.01);
}
#[test]
fn vertical_axis_quantizes_to_one_meter() {
let px = 0.0;
let py = 0.0;
let view_w = 21;
let view_h = 21;
let half_w = (view_w / 2) as i32;
let half_h = (view_h / 2) as i32;
let (gx, gy) =
world_to_grid(3.0, 3.0, px, py, half_w, half_h, view_w, view_h).expect("in view");
let (wx, wy) = grid_to_world(gx, gy, px, py, view_w, view_h).expect("inverse");
assert!(
(wx - 3.0).abs() < 0.01,
"x should stay exact to 1m: got {wx}"
);
assert!(
(wy - 3.0).abs() < 0.01,
"y should stay exact to 1m: got {wy}"
);
}
#[test]
fn square_extent_spans_equal_rows_and_columns() {
let px = 0.0;
let py = 0.0;
let half_w = 50;
let half_h = 50;
let width = 101;
let height = 101;
let (gx0, gy0) =
world_to_grid(-4.0, -4.0, px, py, half_w, half_h, width, height).expect("in view");
let (gx1, gy1) =
world_to_grid(4.0, 4.0, px, py, half_w, half_h, width, height).expect("in view");
let cols_spanned = (gx1 as i32 - gx0 as i32).unsigned_abs();
let rows_spanned = (gy1 as i32 - gy0 as i32).unsigned_abs();
assert_eq!(cols_spanned, 8, "8m wide should span 8 columns");
assert_eq!(rows_spanned, 8, "8m tall should span 8 rows");
}
#[test]
fn resource_paints_on_terrain_cell_for_same_world_coords() {
use flatland_protocol::{ResourceNodeState, ResourceNodeView, TerrainZoneView};
let px = 128.0;
let py = 128.0;
let rx = 131.0;
let ry = 132.0;
let mut state = state_with_building(BuildingView {
id: "x".into(),
label: "X".into(),
x: 128.0,
y: 128.0,
width_m: 1.0,
depth_m: 1.0,
interior_blueprint: None,
tags: vec![],
market_boundary_zone_ids: vec![],
market_max_volume: None,
wall_set: None,
roof_set: None,
});
state.terrain_zones.push(TerrainZoneView {
id: "pond".into(),
x0: rx,
y0: ry,
x1: rx + 1.0,
y1: ry + 1.0,
kind: TerrainKindView::ShallowWater,
elevation: -0.5,
glyph: None,
color: None,
tile_id: None,
z_order: 0,
channel_start_tick: None,
channel_end_tick: None,
});
state.resource_nodes.push(ResourceNodeView {
id: "oak".into(),
label: "Oak".into(),
x: rx,
y: ry,
z: 0.0,
item_template: "oak_log".into(),
state: ResourceNodeState::Available,
blocking: true,
blocking_radius_m: 0.8,
tile_id: None,
yaw: 0.0,
pitch: 0.0,
roll: 0.0,
draw_scale: 1.0,
sprite_mode: None,
growth_progress: None,
presentation_state: None,
channel_start_tick: None,
channel_end_tick: None,
harvest_drop_templates: Vec::new(),});
state.entities[0].transform.position = WorldCoord::surface(px, py);
state.player = state.entities.first().cloned();
let view = WorldView::build_with_target(&state, 25, 15, None);
let half_w = (view.width / 2) as i32;
let half_h = (view.height / 2) as i32;
let (gx, gy) = world_to_grid(rx, ry, px, py, half_w, half_h, view.width, view.height)
.expect("resource in view");
let idx = gy * view.width + gx;
let oak = map_presentation::resource_for(&state.resource_nodes[0]);
assert_eq!(
view.cells[idx], oak.glyph,
"resource should paint on the grid cell for its world coords"
);
let (wx, wy) = grid_to_world(gx, gy, px, py, view.width, view.height).expect("inverse");
assert!(
(wx - rx).abs() < 0.01 && (wy - ry).abs() < 0.01,
"resource grid cell should sample terrain at ({wx}, {wy}), expected ({rx}, {ry})"
);
}
#[test]
fn chest_and_loot_paint_on_non_grass_terrain() {
use flatland_protocol::{GroundDropView, PlacedContainerView, TerrainZoneView};
let px = 50.0;
let py = 50.0;
let cx = 53.0;
let cy = 52.0;
let lx = 54.0;
let ly = 52.0;
let mut state = state_with_building(BuildingView {
id: "x".into(),
label: "X".into(),
x: 50.0,
y: 50.0,
width_m: 1.0,
depth_m: 1.0,
interior_blueprint: None,
tags: vec![],
market_boundary_zone_ids: vec![],
market_max_volume: None,
wall_set: None,
roof_set: None,
});
state.terrain_zones.push(TerrainZoneView {
id: "trail".into(),
x0: 52.0,
y0: 51.0,
x1: 56.0,
y1: 54.0,
kind: TerrainKindView::Trail,
elevation: 0.0,
glyph: None,
color: None,
tile_id: None,
z_order: 0,
channel_start_tick: None,
channel_end_tick: None,
});
state.placed_containers.push(PlacedContainerView {
id: "chest_1".into(),
template_id: "wood_chest".into(),
display_name: "Storage".into(),
x: cx,
y: cy,
z: 0.0,
locked: false,
accessible: true,
owner_character_id: None,
contents: vec![],
lock_id: None,
capacity_volume: Some(40.0),
item_instance_id: None,
tile_id: None,
worker_lodging_capacity: None,
blocking: true,
blocking_radius_m: 0.8,});
state.ground_drops.push(GroundDropView {
id: "drop_1".into(),
template_id: "lumber".into(),
quantity: 2,
x: lx,
y: ly,
z: 0.0,
tile_id: None,
display_name: None,
yaw: 0.0,
pitch: 0.0,
roll: 0.0,
draw_scale: 1.0,
});
state.entities[0].transform.position = WorldCoord::surface(px, py);
state.player = state.entities.first().cloned();
let view = WorldView::build_with_target(&state, 25, 15, None);
let half_w = (view.width / 2) as i32;
let half_h = (view.height / 2) as i32;
let (gx, gy) =
world_to_grid(cx, cy, px, py, half_w, half_h, view.width, view.height).expect("chest");
let chest = map_presentation::chest_presentation(false);
assert_eq!(
view.cells[gy * view.width + gx],
chest.glyph,
"chest must paint on trail/non-grass cells"
);
let (gx2, gy2) =
world_to_grid(lx, ly, px, py, half_w, half_h, view.width, view.height).expect("loot");
let loot = map_presentation::loot_presentation();
assert_eq!(
view.cells[gy2 * view.width + gx2],
loot.glyph,
"ground loot must paint on trail/non-grass cells"
);
}
#[test]
fn build_with_anchor_sets_view_origin() {
let mut state = state_with_building(BuildingView {
id: "x".into(),
label: "X".into(),
x: 128.0,
y: 128.0,
width_m: 1.0,
depth_m: 1.0,
interior_blueprint: None,
tags: vec![],
market_boundary_zone_ids: vec![],
market_max_volume: None,
wall_set: None,
roof_set: None,
});
state.entities[0].transform.position = WorldCoord::surface(100.0, 200.0);
state.player = state.entities.first().cloned();
let view = WorldView::build_with_anchor(
&state,
21,
15,
12.3,
40.7,
None,
WorldViewOptions::terrain_only(),
);
assert!((view.origin_x - 12.3).abs() < 0.01);
assert!((view.origin_y - 40.7).abs() < 0.01);
}
#[test]
fn skip_local_player_glyph_when_paint_local_player_false() {
let mut state = state_with_building(BuildingView {
id: "x".into(),
label: "X".into(),
x: 10.0,
y: 10.0,
width_m: 1.0,
depth_m: 1.0,
interior_blueprint: None,
tags: vec![],
market_boundary_zone_ids: vec![],
market_max_volume: None,
wall_set: None,
roof_set: None,
});
state.entities[0].transform.position = WorldCoord::surface(128.0, 128.0);
state.player = state.entities.first().cloned();
let player_glyph = map_presentation::player_presentation().glyph;
let with_player =
WorldView::build_with_options(&state, 21, 15, None, WorldViewOptions::default());
let cx = with_player.width / 2;
let cy = with_player.height / 2;
assert_eq!(
with_player.cells[cy * with_player.width + cx],
player_glyph,
"default build paints @"
);
let without =
WorldView::build_with_options(&state, 21, 15, None, WorldViewOptions::terrain_only());
assert_ne!(
without.cells[cy * without.width + cx],
player_glyph,
"gfx sprite mode must not paint local player @"
);
}
fn town_hall_state(px: f32, py: f32) -> GameState {
use flatland_protocol::{InteriorMapView, InteriorRoomView};
let mut state = state_with_building(BuildingView {
id: "town_hall".into(),
label: "Town Hall".into(),
x: 156.0,
y: 153.0,
width_m: 20.0,
depth_m: 9.0,
interior_blueprint: Some("town_hall".into()),
tags: vec![],
market_boundary_zone_ids: vec![],
market_max_volume: None,
wall_set: None,
roof_set: None,
});
state.interior_map = Some(InteriorMapView {
building_id: "town_hall".into(),
blueprint_id: "town_hall".into(),
background_color: "#000".into(),
default_floor_color: Some("#2a2a2a".into()),
floor_height_m: 3.0,
z_platforms: vec![],
z_transitions: vec![],
rooms: vec![
InteriorRoomView { id: "main".into(), label: "Main".into(), floor: 0, x0: -3.5, y0: -8.0, x1: 18.5, y1: 6.0, floor_color: None, floor_glyph: None },
InteriorRoomView { id: "kitchen".into(), label: "Kitchen".into(), floor: 0, x0: 18.5, y0: -8.0, x1: 23.5, y1: 0.0, floor_color: None, floor_glyph: None },
InteriorRoomView { id: "weapon".into(), label: "Weapon".into(), floor: 0, x0: 18.5, y0: 0.0, x1: 25.5, y1: 12.5, floor_color: None, floor_glyph: None },
InteriorRoomView { id: "hall_n".into(), label: "Hall N".into(), floor: 0, x0: -3.5, y0: 6.0, x1: 12.0, y1: 12.5, floor_color: None, floor_glyph: None },
InteriorRoomView { id: "meeting".into(), label: "Meeting".into(), floor: 0, x0: -3.5, y0: 12.5, x1: 12.0, y1: 23.5, floor_color: None, floor_glyph: None },
InteriorRoomView { id: "office".into(), label: "Office".into(), floor: 0, x0: 12.0, y0: 6.0, x1: 18.5, y1: 12.5, floor_color: None, floor_glyph: None },
],
room_doors: vec![],
});
state.entities[0].transform.position = WorldCoord::surface(px, py);
state.entities[0].inside_building = Some("town_hall".into());
state.player = state.entities.first().cloned();
state
}
#[test]
fn outer_perimeter_walls_render_at_positive_anchor() {
let state = town_hall_state(20.0, 6.0);
let view = WorldView::build_with_anchor(
&state,
31,
31,
20.0,
6.0,
None,
WorldViewOptions::terrain_only(),
);
let cell = |x: i32, y: i32| {
let (gx, gy) =
world_to_grid(x as f32, y as f32, 20.0, 6.0, 15, 15, view.width, view.height)
.expect("in view");
view.cells[gy * view.width + gx].clone()
};
assert!(is_wall(&cell(26, 9)), "weapon east wall at (26,9): {}", cell(26, 9));
assert!(
cell(26, 13) == "+" || cell(26, 13) == "-" || cell(26, 13) == "|",
"top wall east corner (26,13): {}",
cell(26, 13)
);
assert!(is_wall(&cell(19, 9)), "office east wall at (19,9): {}", cell(19, 9));
assert!(is_wall(&cell(24, -4)), "kitchen east wall at (24,-4): {}", cell(24, -4));
assert!(
cell(24, -8) == "+" || cell(24, -8) == "-" || cell(24, -8) == "|",
"bottom wall kitchen-east corner (24,-8): {}",
cell(24, -8)
);
assert!(
cell(12, 13) == "+" || cell(12, 13) == "-" || cell(12, 13) == "|",
"top wall at (12,13): {}",
cell(12, 13)
);
}
}