use crate::color::RgbColor;
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())
{
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,
);
let door_gaps = interior_door_gaps(interior, &state.doors);
for room in &interior.rooms {
paint_building_walls(
&mut cells,
&mut cell_fg,
width,
height,
room.x0,
room.y0,
room.x1 - room.x0,
room.y1 - room.y0,
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 {
if let Some((gx, gy)) =
world_to_grid(door.x, door.y, 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(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,
) {
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 {
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],
) -> Vec<(f32, f32)> {
let mut gaps: Vec<(f32, f32)> = interior
.room_doors
.iter()
.filter_map(|d| {
doors
.iter()
.find(|door| door.id == d.id)
.filter(|door| door.open)
.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 near_door_gap(wx: i32, wy: i32, door_gaps: &[(f32, f32)]) -> bool {
door_gaps
.iter()
.any(|(dx, dy)| (wx as f32 - dx).abs() < 0.75 && (wy as f32 - dy).abs() < 0.75)
}
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 || 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 {
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,
}],
player: None,
resource_nodes: vec![],
ground_drops: vec![],
placed_containers: vec![],
buildings: vec![building],
doors: vec![],
interior_map: None,
npcs: vec![],
blueprints: vec![],
world_width_m: 256.0,
world_height_m: 256.0,
terrain_zones: vec![],
z_platforms: vec![],
z_transitions: vec![],
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,
storage_menu_index: 0,
storage_ui_mode: flatland_client_lib::StorageUiMode::Menu,
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,
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,
ability_cooldowns: Vec::new(),
blocking_active: false,
max_target_slots: 1,
combat_slots: Vec::new(),
rotation_presets: Vec::new(),
known_abilities: Vec::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,
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![],
});
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,
});
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,
}];
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![],
});
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,
});
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,
}];
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()],
};
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,
}];
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![],
};
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![],
};
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,
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,
}];
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![],
};
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,
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![],
};
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,
}];
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,
paperdoll_ref: None,
yaw: 0.0,
pitch: 0.0,
roll: 0.0,
draw_scale: 1.0,
sprite_mode: None,
presentation_state: None,
});
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,
paperdoll_ref: None,
yaw: 0.0,
pitch: 0.0,
roll: 0.0,
draw_scale: 1.0,
sprite_mode: None,
presentation_state: None,
});
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![],
};
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![],
};
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,
}];
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![],
};
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,
},
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,
},
];
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![],
};
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,
},
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,
},
];
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![],
});
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,
});
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,
paperdoll_ref: None,
yaw: 0.0,
pitch: 0.0,
roll: 0.0,
draw_scale: 1.0,
sprite_mode: None,
presentation_state: None,
});
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![],
});
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,
});
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,
paperdoll_ref: None,
worker_lodging_capacity: None,
});
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,
paperdoll_ref: 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![],
});
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![],
});
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 @"
);
}
}