Skip to main content

flatland_client_ui/
world.rs

1use crate::color::RgbColor;
2use crate::interior_wall_layout::{interior_door_display_xy, interior_wall_glyph_line};
3use flatland_client_lib::GameState;
4use flatland_protocol::{BuildingView, InteriorMapView, TerrainKindView};
5
6use crate::map_presentation::{self, MapPresentation};
7
8/// The TUI map uses one terminal cell per world meter on both axes so terrain, entities,
9/// resources, and mouse targeting share the same grid. Terminal fonts are taller than wide,
10/// so buildings look vertically stretched compared to the content-admin editor.
11
12pub struct WorldView {
13    pub width: usize,
14    pub height: usize,
15    pub cells: Vec<String>,
16    /// Per-cell foreground color from presentation catalog (None = entity-specific style).
17    pub cell_fg: Vec<Option<RgbColor>>,
18    /// T1 target ring (red).
19    pub target_t1_cells: Vec<bool>,
20    /// T2 target ring (blue).
21    pub target_t2_cells: Vec<bool>,
22    pub origin_x: f32,
23    pub origin_y: f32,
24    pub inside_building: Option<String>,
25}
26
27/// Presentation flags for [`WorldView::build_with_options`].
28#[derive(Debug, Clone, Copy)]
29pub struct WorldViewOptions {
30    /// Paint local player `@` at view center (TUI). Gfx uses a sprite instead.
31    pub paint_local_player: bool,
32    /// Paint NPCs, resources, loot, chests, quest boards, entities into the cell grid.
33    /// Gfx sets this false and draws those at continuous world coordinates.
34    pub paint_overlays: bool,
35}
36
37impl Default for WorldViewOptions {
38    fn default() -> Self {
39        Self {
40            paint_local_player: true,
41            paint_overlays: true,
42        }
43    }
44}
45
46impl WorldViewOptions {
47    /// Terrain / walls only — gfx draws movers at precise x,y.
48    pub fn terrain_only() -> Self {
49        Self {
50            paint_local_player: false,
51            paint_overlays: false,
52        }
53    }
54}
55
56impl WorldView {
57    pub fn build_with_target(
58        state: &GameState,
59        view_w: usize,
60        view_h: usize,
61        map_target: Option<(f32, f32)>,
62    ) -> Self {
63        Self::build_with_options(
64            state,
65            view_w,
66            view_h,
67            map_target,
68            WorldViewOptions::default(),
69        )
70    }
71
72    /// Like [`build_with_target`] with gfx/TUI presentation flags.
73    pub fn build_with_options(
74        state: &GameState,
75        view_w: usize,
76        view_h: usize,
77        map_target: Option<(f32, f32)>,
78        options: WorldViewOptions,
79    ) -> Self {
80        let (px, py) = state.player_position();
81        Self::build_with_anchor(state, view_w, view_h, px, py, map_target, options)
82    }
83
84    /// Build a view anchored at explicit world coordinates (gfx uses smoothed camera).
85    pub fn build_with_anchor(
86        state: &GameState,
87        view_w: usize,
88        view_h: usize,
89        anchor_x: f32,
90        anchor_y: f32,
91        map_target: Option<(f32, f32)>,
92        options: WorldViewOptions,
93    ) -> Self {
94        map_presentation::maybe_reload_for_content_rev(state.content_rev);
95        let px = anchor_x;
96        let py = anchor_y;
97        let inside_building = state.effective_inside_building();
98
99        let width = view_w.max(3);
100        let height = view_h.max(3);
101        let grass = map_presentation::terrain_for(TerrainKindView::Grass);
102        let mut cells = vec![grass.glyph.clone(); width * height];
103        let mut cell_fg = vec![Some(grass.color); width * height];
104
105        let half_w = (width / 2) as i32;
106        let half_h = (height / 2) as i32;
107
108        // Interior rendering is authoritative from `inside_building`, not stale `interior_map`.
109        if let (Some(_bid), Some(interior)) =
110            (inside_building.as_deref(), state.interior_map.as_ref())
111        {
112            let player_z = state
113                .player
114                .as_ref()
115                .map(|p| p.transform.position.z)
116                .unwrap_or(0.0);
117            let active_floor = if interior.floor_height_m > f32::EPSILON {
118                (player_z / interior.floor_height_m).round() as i32
119            } else {
120                0
121            };
122            paint_interior_background(&mut cells, &mut cell_fg, width, height, interior);
123            paint_interior_rooms(
124                &mut cells,
125                &mut cell_fg,
126                width,
127                height,
128                interior,
129                px,
130                py,
131                half_w,
132                half_h,
133                active_floor,
134            );
135            let door_gaps = interior_door_gaps_snapped(interior, &state.doors, active_floor);
136            paint_interior_merged_walls(
137                &mut cells,
138                &mut cell_fg,
139                width,
140                height,
141                &interior.rooms,
142                active_floor,
143                px,
144                py,
145                half_w,
146                half_h,
147                &door_gaps,
148            );
149        } else {
150            paint_terrain(
151                &mut cells,
152                &mut cell_fg,
153                width,
154                height,
155                state,
156                px,
157                py,
158                half_w,
159                half_h,
160            );
161
162            for building in &state.buildings {
163                if building.tags.iter().any(|t| t == "well") {
164                    paint_well(
165                        &mut cells,
166                        &mut cell_fg,
167                        width,
168                        height,
169                        building,
170                        px,
171                        py,
172                        half_w,
173                        half_h,
174                    );
175                } else {
176                    paint_building_walls_centered(
177                        &mut cells,
178                        &mut cell_fg,
179                        width,
180                        height,
181                        building,
182                        px,
183                        py,
184                        half_w,
185                        half_h,
186                    );
187                }
188            }
189        }
190
191        if options.paint_overlays {
192            for door in &state.doors {
193                let (wx, wy) = match (inside_building.as_ref(), state.interior_map.as_ref()) {
194                    (Some(_), Some(interior)) => {
195                        let player_z = state
196                            .player
197                            .as_ref()
198                            .map(|p| p.transform.position.z)
199                            .unwrap_or(0.0);
200                        let active_floor = if interior.floor_height_m > f32::EPSILON {
201                            (player_z / interior.floor_height_m).round() as i32
202                        } else {
203                            0
204                        };
205                        interior_door_display_xy(interior, active_floor, &door.id, door.x, door.y)
206                    }
207                    _ => (door.x, door.y),
208                };
209                if let Some((gx, gy)) = world_to_grid(wx, wy, px, py, half_w, half_h, width, height)
210                {
211                    let idx = gy * width + gx;
212                    paint_presentation(
213                        &mut cells,
214                        &mut cell_fg,
215                        idx,
216                        &map_presentation::door_presentation(door.open, door.locked),
217                    );
218                }
219            }
220
221            for npc in &state.npcs {
222                if let Some((gx, gy)) =
223                    world_to_grid(npc.x, npc.y, px, py, half_w, half_h, width, height)
224                {
225                    let idx = gy * width + gx;
226                    paint_presentation(
227                        &mut cells,
228                        &mut cell_fg,
229                        idx,
230                        &map_presentation::npc_for(npc),
231                    );
232                }
233            }
234
235            let ground = empty_ground_glyph();
236            let player_glyph = map_presentation::player_presentation().glyph;
237            for node in &state.resource_nodes {
238                if let Some((gx, gy)) =
239                    world_to_grid(node.x, node.y, px, py, half_w, half_h, width, height)
240                {
241                    let idx = gy * width + gx;
242                    let pres = map_presentation::resource_for(node);
243                    if can_paint_world_object(&cells[idx], &ground, &player_glyph) {
244                        paint_presentation(&mut cells, &mut cell_fg, idx, &pres);
245                    }
246                }
247            }
248
249            for drop in &state.ground_drops {
250                if let Some((gx, gy)) =
251                    world_to_grid(drop.x, drop.y, px, py, half_w, half_h, width, height)
252                {
253                    let idx = gy * width + gx;
254                    if can_paint_world_object(&cells[idx], &ground, &player_glyph) {
255                        paint_presentation(
256                            &mut cells,
257                            &mut cell_fg,
258                            idx,
259                            &map_presentation::loot_presentation(),
260                        );
261                    }
262                }
263            }
264
265            for chest in &state.placed_containers {
266                if !state.placed_container_in_current_space(chest) {
267                    continue;
268                }
269                if let Some((gx, gy)) =
270                    world_to_grid(chest.x, chest.y, px, py, half_w, half_h, width, height)
271                {
272                    let idx = gy * width + gx;
273                    if can_paint_world_object(&cells[idx], &ground, &player_glyph) {
274                        paint_presentation(
275                            &mut cells,
276                            &mut cell_fg,
277                            idx,
278                            &map_presentation::chest_presentation(chest.locked),
279                        );
280                    }
281                }
282            }
283
284            if state.effective_inside_building().is_none() {
285                for inter in &state.interactables {
286                    if inter.kind != "quest_board" {
287                        continue;
288                    }
289                    if let Some((gx, gy)) =
290                        world_to_grid(inter.x, inter.y, px, py, half_w, half_h, width, height)
291                    {
292                        let idx = gy * width + gx;
293                        if can_paint_world_object(&cells[idx], &ground, &player_glyph) {
294                            paint_presentation(
295                                &mut cells,
296                                &mut cell_fg,
297                                idx,
298                                &map_presentation::quest_board_presentation(),
299                            );
300                        }
301                    }
302                }
303            }
304
305            for entity in &state.entities {
306                // When gfx draws a sprite for the local player, omit the `@` glyph.
307                if !options.paint_local_player && entity.id == state.entity_id {
308                    continue;
309                }
310                if entity.id != state.entity_id
311                    && entity.inside_building.as_deref() != inside_building.as_deref()
312                {
313                    continue;
314                }
315                if let (Some(_), Some(interior)) =
316                    (inside_building.as_deref(), state.interior_map.as_ref())
317                {
318                    let player_z = state
319                        .player
320                        .as_ref()
321                        .map(|p| p.transform.position.z)
322                        .unwrap_or(0.0);
323                    if interior.floor_height_m > f32::EPSILON {
324                        let pf = (player_z / interior.floor_height_m).round() as i32;
325                        let ef =
326                            (entity.transform.position.z / interior.floor_height_m).round() as i32;
327                        if pf != ef {
328                            continue;
329                        }
330                    }
331                }
332                if let Some(pres) = entity_presentation(entity, state.entity_id) {
333                    if let Some((gx, gy)) = world_to_grid(
334                        entity.transform.position.x,
335                        entity.transform.position.y,
336                        px,
337                        py,
338                        half_w,
339                        half_h,
340                        width,
341                        height,
342                    ) {
343                        let idx = gy * width + gx;
344                        paint_presentation(&mut cells, &mut cell_fg, idx, &pres);
345                    }
346                }
347            }
348        }
349
350        if options.paint_local_player {
351            if state.player_entity().is_some() {
352                let cx = half_w as usize;
353                let cy = half_h as usize;
354                let idx = cy * width + cx;
355                if idx < cells.len() {
356                    paint_presentation(
357                        &mut cells,
358                        &mut cell_fg,
359                        idx,
360                        &map_presentation::player_presentation(),
361                    );
362                }
363            }
364        }
365
366        let mut target_t1_cells = vec![false; cells.len()];
367        let mut target_t2_cells = vec![false; cells.len()];
368        if options.paint_overlays {
369            for (slot, cells_out) in [(1, &mut target_t1_cells), (2, &mut target_t2_cells)] {
370                let target_id = state
371                    .combat_slots
372                    .iter()
373                    .find(|s| s.slot_index == slot)
374                    .and_then(|s| s.target_entity_id)
375                    .or_else(|| if slot == 1 { state.combat_target } else { None });
376                let Some(target_id) = target_id else {
377                    continue;
378                };
379                let (tx, ty) =
380                    if let Some(npc) = state.npcs.iter().find(|n| n.entity_id == Some(target_id)) {
381                        (npc.x, npc.y)
382                    } else if let Some(entity) = state.entities.iter().find(|e| e.id == target_id) {
383                        (entity.transform.position.x, entity.transform.position.y)
384                    } else {
385                        continue;
386                    };
387                if let Some((gx, gy)) = world_to_grid(tx, ty, px, py, half_w, half_h, width, height)
388                {
389                    let idx = gy * width + gx;
390                    if idx < cells_out.len() {
391                        cells_out[idx] = true;
392                    }
393                }
394            }
395        }
396
397        if options.paint_overlays {
398            if let Some((tx, ty)) = map_target {
399                if let Some((gx, gy)) = world_to_grid(tx, ty, px, py, half_w, half_h, width, height)
400                {
401                    let idx = gy * width + gx;
402                    if idx < cells.len() {
403                        cells[idx] = "X".into();
404                        cell_fg[idx] = Some(RgbColor::YELLOW);
405                    }
406                }
407            }
408        }
409
410        Self {
411            width,
412            height,
413            cells,
414            cell_fg,
415            target_t1_cells,
416            target_t2_cells,
417            origin_x: px,
418            origin_y: py,
419            inside_building,
420        }
421    }
422}
423
424fn paint_presentation(
425    cells: &mut [String],
426    cell_fg: &mut [Option<RgbColor>],
427    idx: usize,
428    pres: &MapPresentation,
429) {
430    cells[idx] = pres.glyph.clone();
431    cell_fg[idx] = Some(pres.color);
432}
433
434fn empty_ground_glyph() -> String {
435    map_presentation::terrain_for(TerrainKindView::Grass).glyph
436}
437
438fn entity_presentation(
439    entity: &flatland_protocol::EntityState,
440    player_id: u64,
441) -> Option<MapPresentation> {
442    if entity.id == player_id {
443        return Some(map_presentation::player_presentation());
444    }
445    if entity
446        .vitals
447        .as_ref()
448        .is_some_and(|v| v.life_state == flatland_protocol::LifeState::Dead)
449    {
450        return Some(map_presentation::corpse_presentation());
451    }
452    Some(map_presentation::entity_fallback(&entity.label))
453}
454
455fn paint_interior_background(
456    cells: &mut [String],
457    cell_fg: &mut [Option<RgbColor>],
458    _width: usize,
459    _height: usize,
460    interior: &InteriorMapView,
461) {
462    let bg = crate::color::parse_color(&interior.background_color).unwrap_or(RgbColor::BLACK);
463    for idx in 0..cells.len() {
464        cells[idx] = " ".into();
465        cell_fg[idx] = Some(bg);
466    }
467}
468
469fn paint_interior_rooms(
470    cells: &mut [String],
471    cell_fg: &mut [Option<RgbColor>],
472    width: usize,
473    height: usize,
474    interior: &InteriorMapView,
475    px: f32,
476    py: f32,
477    half_w: i32,
478    half_h: i32,
479    active_floor: i32,
480) {
481    let default_color = interior
482        .default_floor_color
483        .as_deref()
484        .and_then(crate::color::parse_color)
485        .unwrap_or(RgbColor::rgb(0x2a, 0x2a, 0x2a));
486    for room in &interior.rooms {
487        if room.floor != active_floor {
488            continue;
489        }
490        let floor_color = room
491            .floor_color
492            .as_deref()
493            .and_then(crate::color::parse_color)
494            .unwrap_or(default_color);
495        let glyph = room.floor_glyph.as_deref().unwrap_or(".").to_string();
496        let x0 = room.x0.floor() as i32;
497        let y0 = room.y0.floor() as i32;
498        let x1 = room.x1.ceil() as i32 - 1;
499        let y1 = room.y1.ceil() as i32 - 1;
500        for wy in y0..=y1 {
501            for wx in x0..=x1 {
502                if let Some((gx, gy)) =
503                    world_to_grid(wx as f32, wy as f32, px, py, half_w, half_h, width, height)
504                {
505                    let idx = gy * width + gx;
506                    cells[idx] = glyph.clone();
507                    cell_fg[idx] = Some(floor_color);
508                }
509            }
510        }
511    }
512}
513
514fn interior_door_gaps(
515    interior: &InteriorMapView,
516    doors: &[flatland_protocol::DoorView],
517    active_floor: i32,
518) -> Vec<(f32, f32)> {
519    let room_floor =
520        |id: &str| -> Option<i32> { interior.rooms.iter().find(|r| r.id == id).map(|r| r.floor) };
521    let mut gaps: Vec<(f32, f32)> = interior
522        .room_doors
523        .iter()
524        .filter_map(|d| {
525            let on_floor = room_floor(&d.room_a) == Some(active_floor)
526                || room_floor(&d.room_b) == Some(active_floor);
527            if !on_floor {
528                return None;
529            }
530            doors
531                .iter()
532                .find(|door| door.id == d.id)
533                .filter(|door| door.open || d.kind == "stairs")
534                .map(|door| (door.x, door.y))
535        })
536        .collect();
537    for door in doors {
538        if door.portal.is_some() && door.open {
539            gaps.push((door.x, door.y));
540        }
541    }
542    gaps
543}
544
545fn interior_door_gaps_snapped(
546    interior: &flatland_protocol::InteriorMapView,
547    doors: &[flatland_protocol::DoorView],
548    floor: i32,
549) -> Vec<(f32, f32)> {
550    let mut gaps = Vec::new();
551    for (x, y) in interior_door_gaps(interior, doors, floor) {
552        // Match gap carving to door sprite — find door id at this authored position.
553        let door_id = doors
554            .iter()
555            .find(|d| (d.x - x).abs() < 0.05 && (d.y - y).abs() < 0.05)
556            .map(|d| d.id.as_str())
557            .unwrap_or("");
558        gaps.push(interior_door_display_xy(interior, floor, door_id, x, y));
559    }
560    gaps
561}
562
563fn near_door_gap(wx: i32, wy: i32, door_gaps: &[(f32, f32)]) -> bool {
564    door_gaps
565        .iter()
566        .any(|(dx, dy)| (wx as f32 - dx).abs() < 1.0 && (wy as f32 - dy).abs() < 1.0)
567}
568
569const INTERIOR_WALL_EDGE_TOL: f32 = 0.6;
570
571/// World grid row/column for a merged wall line (half-meter room edges).
572fn interior_wall_grid_line(fixed: f32) -> i32 {
573    interior_wall_glyph_line(fixed)
574}
575
576fn quant_interior_wall_coord(v: f32) -> i64 {
577    (v * 1000.0).round() as i64
578}
579
580fn merge_interior_wall_intervals(mut intervals: Vec<(f32, f32)>) -> Vec<(f32, f32)> {
581    if intervals.is_empty() {
582        return intervals;
583    }
584    intervals.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
585    let mut out = vec![intervals[0]];
586    for &(a, b) in intervals.iter().skip(1) {
587        let last_idx = out.len() - 1;
588        if a <= out[last_idx].1 + INTERIOR_WALL_EDGE_TOL {
589            out[last_idx].1 = out[last_idx].1.max(b);
590        } else {
591            out.push((a, b));
592        }
593    }
594    out
595}
596
597/// Paint merged interior walls — shared room edges become a single wall strip (matches sim).
598fn paint_interior_merged_walls(
599    cells: &mut [String],
600    cell_fg: &mut [Option<RgbColor>],
601    width: usize,
602    height: usize,
603    rooms: &[flatland_protocol::InteriorRoomView],
604    floor: i32,
605    px: f32,
606    py: f32,
607    half_w: i32,
608    half_h: i32,
609    door_gaps: &[(f32, f32)],
610) {
611    use std::collections::HashMap;
612
613    let mut horiz: HashMap<i64, Vec<(f32, f32)>> = HashMap::new();
614    let mut vert: HashMap<i64, Vec<(f32, f32)>> = HashMap::new();
615
616    for room in rooms.iter().filter(|r| r.floor == floor) {
617        let west = room.x0.min(room.x1);
618        let east = room.x0.max(room.x1);
619        let south = room.y0.min(room.y1);
620        let north = room.y0.max(room.y1);
621        if east - west >= 0.25 {
622            horiz
623                .entry(quant_interior_wall_coord(south))
624                .or_default()
625                .push((west, east));
626            horiz
627                .entry(quant_interior_wall_coord(north))
628                .or_default()
629                .push((west, east));
630        }
631        if north - south >= 0.25 {
632            vert.entry(quant_interior_wall_coord(west))
633                .or_default()
634                .push((south, north));
635            vert.entry(quant_interior_wall_coord(east))
636                .or_default()
637                .push((south, north));
638        }
639    }
640
641    for (key, intervals) in horiz {
642        let fixed = key as f32 / 1000.0;
643        let merged = merge_interior_wall_intervals(intervals);
644        let wy = interior_wall_grid_line(fixed);
645        for (start, end) in merged {
646            if end - start < 0.2 {
647                continue;
648            }
649            let x0 = start.round() as i32;
650            let x1 = end.round() as i32;
651            for wx in x0..=x1 {
652                if !near_door_gap(wx, wy, door_gaps) {
653                    paint_wall_cell(
654                        cells, cell_fg, width, height, wx, wy, px, py, half_w, half_h, "-",
655                    );
656                }
657            }
658        }
659    }
660
661    for (key, intervals) in vert {
662        let fixed = key as f32 / 1000.0;
663        let merged = merge_interior_wall_intervals(intervals);
664        let wx = interior_wall_grid_line(fixed);
665        for (start, end) in merged {
666            if end - start < 0.2 {
667                continue;
668            }
669            let y0 = start.round() as i32;
670            let y1 = end.round() as i32;
671            for wy in y0..=y1 {
672                if !near_door_gap(wx, wy, door_gaps) {
673                    paint_wall_cell(
674                        cells, cell_fg, width, height, wx, wy, px, py, half_w, half_h, "|",
675                    );
676                }
677            }
678        }
679    }
680}
681
682fn paint_terrain(
683    cells: &mut [String],
684    cell_fg: &mut [Option<RgbColor>],
685    width: usize,
686    height: usize,
687    state: &GameState,
688    px: f32,
689    py: f32,
690    _half_w: i32,
691    _half_h: i32,
692) {
693    for gy in 0..height {
694        for gx in 0..width {
695            let Some((wx, wy)) = grid_to_world(gx, gy, px, py, width, height) else {
696                continue;
697            };
698            let zone = state.terrain_zone_at(wx, wy);
699            let kind = zone.map(|z| z.kind).unwrap_or(TerrainKindView::Grass);
700            let elev = zone.map(|z| z.elevation).unwrap_or(0.0);
701            let style = map_presentation::terrain_for_zone(
702                kind,
703                elev,
704                zone.and_then(|z| z.glyph.as_deref()),
705                zone.and_then(|z| z.color.as_deref()),
706            );
707            let idx = gy * width + gx;
708            paint_presentation(cells, cell_fg, idx, &style);
709        }
710    }
711}
712
713fn paint_well(
714    cells: &mut [String],
715    cell_fg: &mut [Option<RgbColor>],
716    width: usize,
717    height: usize,
718    building: &BuildingView,
719    px: f32,
720    py: f32,
721    half_w: i32,
722    half_h: i32,
723) {
724    let hw = building.width_m / 2.0;
725    let hd = building.depth_m / 2.0;
726    let x0 = (building.x - hw).floor() as i32;
727    let y0 = (building.y - hd).floor() as i32;
728    let x1 = (building.x + hw).ceil() as i32 - 1;
729    let y1 = (building.y + hd).ceil() as i32 - 1;
730    let water = map_presentation::shallow_water_presentation();
731    let center = map_presentation::well_center_presentation();
732
733    for wy in y0..=y1 {
734        for wx in x0..=x1 {
735            let pres = if wx == building.x.round() as i32 && wy == building.y.round() as i32 {
736                center.clone()
737            } else {
738                water.clone()
739            };
740            if let Some((gx, gy)) =
741                world_to_grid(wx as f32, wy as f32, px, py, half_w, half_h, width, height)
742            {
743                let idx = gy * width + gx;
744                if !is_wall(&cells[idx]) {
745                    paint_presentation(cells, cell_fg, idx, &pres);
746                }
747            }
748        }
749    }
750}
751
752fn paint_building_walls_centered(
753    cells: &mut [String],
754    cell_fg: &mut [Option<RgbColor>],
755    width: usize,
756    height: usize,
757    building: &BuildingView,
758    px: f32,
759    py: f32,
760    half_w: i32,
761    half_h: i32,
762) {
763    let hw = building.width_m / 2.0;
764    let hd = building.depth_m / 2.0;
765    paint_building_walls(
766        cells,
767        cell_fg,
768        width,
769        height,
770        building.x - hw,
771        building.y - hd,
772        building.width_m,
773        building.depth_m,
774        px,
775        py,
776        half_w,
777        half_h,
778        &[],
779    );
780}
781
782/// Paint perimeter walls for an axis-aligned rectangle (origin = south-west corner).
783fn paint_building_walls(
784    cells: &mut [String],
785    cell_fg: &mut [Option<RgbColor>],
786    width: usize,
787    height: usize,
788    origin_x: f32,
789    origin_y: f32,
790    width_m: f32,
791    depth_m: f32,
792    px: f32,
793    py: f32,
794    half_w: i32,
795    half_h: i32,
796    door_gaps: &[(f32, f32)],
797) {
798    let x0 = origin_x.floor() as i32;
799    let y0 = origin_y.floor() as i32;
800    let x1 = (origin_x + width_m).ceil() as i32 - 1;
801    let y1 = (origin_y + depth_m).ceil() as i32 - 1;
802
803    if x1 < x0 || y1 < y0 {
804        return;
805    }
806
807    for wx in x0..=x1 {
808        if !near_door_gap(wx, y0, door_gaps) {
809            paint_wall_cell(
810                cells, cell_fg, width, height, wx, y0, px, py, half_w, half_h, "-",
811            );
812        }
813        if !near_door_gap(wx, y1, door_gaps) {
814            paint_wall_cell(
815                cells, cell_fg, width, height, wx, y1, px, py, half_w, half_h, "-",
816            );
817        }
818    }
819    for wy in y0 + 1..y1 {
820        if !near_door_gap(x0, wy, door_gaps) {
821            paint_wall_cell(
822                cells, cell_fg, width, height, x0, wy, px, py, half_w, half_h, "|",
823            );
824        }
825        if !near_door_gap(x1, wy, door_gaps) {
826            paint_wall_cell(
827                cells, cell_fg, width, height, x1, wy, px, py, half_w, half_h, "|",
828            );
829        }
830    }
831    if !near_door_gap(x0, y0, door_gaps) {
832        paint_wall_cell(
833            cells, cell_fg, width, height, x0, y0, px, py, half_w, half_h, "+",
834        );
835    }
836    if !near_door_gap(x1, y0, door_gaps) {
837        paint_wall_cell(
838            cells, cell_fg, width, height, x1, y0, px, py, half_w, half_h, "+",
839        );
840    }
841    if !near_door_gap(x0, y1, door_gaps) {
842        paint_wall_cell(
843            cells, cell_fg, width, height, x0, y1, px, py, half_w, half_h, "+",
844        );
845    }
846    if !near_door_gap(x1, y1, door_gaps) {
847        paint_wall_cell(
848            cells, cell_fg, width, height, x1, y1, px, py, half_w, half_h, "+",
849        );
850    }
851}
852
853fn paint_wall_cell(
854    cells: &mut [String],
855    cell_fg: &mut [Option<RgbColor>],
856    width: usize,
857    height: usize,
858    wx: i32,
859    wy: i32,
860    px: f32,
861    py: f32,
862    half_w: i32,
863    half_h: i32,
864    ch: &str,
865) {
866    let Some((gx, gy)) = world_to_grid(wx as f32, wy as f32, px, py, half_w, half_h, width, height)
867    else {
868        return;
869    };
870    let idx = gy * width + gx;
871    let ground = empty_ground_glyph();
872    // Paint over terrain/floor, the interior background, and existing wall cells
873    // (corner merge). Skipping the " " interior background is what made outer
874    // perimeter walls (e.g. the south wall of a room at negative y) invisible.
875    if cells[idx] == ground || cells[idx] == " " || is_wall(&cells[idx]) {
876        cells[idx] = merge_wall_corner(&cells[idx], ch, &ground);
877        cell_fg[idx] = Some(map_presentation::wall_presentation().color);
878    }
879}
880
881fn is_wall(glyph: &str) -> bool {
882    matches!(glyph.chars().next(), Some('+' | '-' | '|'))
883}
884
885/// Overlays (resources, loot, chests, boards) paint on any non-wall cell.
886fn can_paint_world_object(glyph: &str, _ground: &str, _player_glyph: &str) -> bool {
887    !is_wall(glyph)
888}
889
890fn merge_wall_corner(existing: &str, incoming: &str, ground: &str) -> String {
891    // Interior background counts as paintable ground (outer perimeter walls sit on it).
892    if existing == ground || existing == " " {
893        return incoming.to_string();
894    }
895    if existing == incoming {
896        return existing.to_string();
897    }
898    "+".to_string()
899}
900
901fn world_to_grid(
902    x: f32,
903    y: f32,
904    px: f32,
905    py: f32,
906    half_w: i32,
907    half_h: i32,
908    width: usize,
909    height: usize,
910) -> Option<(usize, usize)> {
911    let dx = (x - px).round() as i32;
912    let dy = (y - py).round() as i32;
913
914    if dx.abs() > half_w || dy.abs() > half_h {
915        return None;
916    }
917
918    let gx = half_w + dx;
919    let gy = half_h - dy;
920
921    if gx < 0 || gy < 0 {
922        return None;
923    }
924    let gx = gx as usize;
925    let gy = gy as usize;
926    if gx >= width || gy >= height {
927        return None;
928    }
929    Some((gx, gy))
930}
931
932/// Inverse of [`world_to_grid`]: map view cell → world meters (cell center).
933pub fn grid_to_world(
934    gx: usize,
935    gy: usize,
936    px: f32,
937    py: f32,
938    view_w: usize,
939    view_h: usize,
940) -> Option<(f32, f32)> {
941    if gx >= view_w || gy >= view_h {
942        return None;
943    }
944    let half_w = (view_w / 2) as i32;
945    let half_h = (view_h / 2) as i32;
946    let dx = gx as i32 - half_w;
947    let dy = half_h - gy as i32;
948    Some((px + dx as f32, py + dy as f32))
949}
950
951#[cfg(test)]
952mod tests {
953    use super::*;
954    use flatland_protocol::{
955        BuildingView, EntityState, PlayerVitals, PrimaryAttributes, Transform, WorldCoord,
956    };
957
958    fn state_with_building(building: BuildingView) -> GameState {
959        GameState {
960            session_id: 1,
961            entity_id: 1,
962            character_id: None,
963            tick: 0,
964            chunk_rev: 0,
965            content_rev: 0,
966            publish_rev: 0,
967            entities: vec![EntityState {
968                id: 1,
969                label: "You".into(),
970                transform: Transform {
971                    position: WorldCoord::surface(148.0, 118.0),
972                    yaw: 0.0,
973                    velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
974                },
975                vitals: Some(PlayerVitals::default()),
976                attributes: Some(PrimaryAttributes::default()),
977                skills: Some(flatland_protocol::PlayerSkills::default()),
978                inside_building: None,
979                tile_id: None,
980                paperdoll_ref: None,
981                draw_scale: 1.0,
982                presentation_state: None,
983                sprite_mode: None,
984                progression_xp: None,
985                combat_cues: Vec::new(),
986                statuses: Vec::new(),
987            }],
988            player: None,
989            resource_nodes: vec![],
990            ground_drops: vec![],
991            placed_containers: vec![],
992            buildings: vec![building],
993            doors: vec![],
994            interior_map: None,
995            npcs: vec![],
996            blueprints: vec![],
997            building_materials: vec![],
998            world_x0: 0.0,
999            world_y0: 0.0,
1000            world_width_m: 256.0,
1001            world_height_m: 256.0,
1002            terrain_zones: vec![],
1003            z_platforms: vec![],
1004            z_transitions: vec![],
1005            z_bands_outdoor_backup: None,
1006            world_clock: flatland_protocol::WorldClock::default(),
1007            inventory: Default::default(),
1008            inventory_hints: Default::default(),
1009            item_catalog: Default::default(),
1010            logs: Default::default(),
1011            intents_sent: 0,
1012            ticks_received: 0,
1013            connected: true,
1014            disconnect_reason: None,
1015            show_stats: false,
1016            hud_log_hidden: false,
1017            show_equip_menu: false,
1018            equip_menu_index: 0,
1019            ledger: None,
1020            career: None,
1021            character_sheet_tab: flatland_client_lib::CharacterSheetTab::Character,
1022            ledger_period: flatland_client_lib::LedgerPeriod::Day,
1023            show_craft_menu: false,
1024            show_plot_build_menu: false,
1025            plot_build_focus_wall: true,
1026            plot_build_wall_index: 0,
1027            plot_build_roof_index: 0,
1028            craft_menu_index: 0,
1029            craft_batch_quantity: 1,
1030            craft_tab: flatland_client_lib::CraftTab::Ready,
1031            craft_filter: String::new(),
1032            craft_filter_focused: false,
1033            craft_prefs: Default::default(),
1034            show_shop_menu: false,
1035            shop_catalog: None,
1036            bank_panel: None,
1037            bank_menu_index: 0,
1038            bank_ui_mode: flatland_client_lib::BankUiMode::Menu,
1039            storage_panel: None,
1040            market_panel: None,
1041            market_menu_index: 0,
1042            market_filter: String::new(),
1043            market_filter_focused: false,
1044            market_category_filter: None,
1045            market_buy_confirm: None,
1046            market_ui_mode: flatland_client_lib::MarketUiMode::Browse,
1047            storage_menu_index: 0,
1048            storage_ui_mode: flatland_client_lib::StorageUiMode::Menu,
1049            property_zones: vec![],
1050            tax_zones: vec![],
1051            growth_zones: vec![],
1052            biome_zones: vec![],
1053            terrain_kind_nav: vec![],
1054            property_plots: vec![],
1055            property_plot_settings: None,
1056            claim_mode: None,
1057            relocate_mode: None,
1058            sell_plot_confirm: None,
1059            sell_plot_armed_at: None,
1060            show_plant_menu: false,
1061            plant_menu_index: 0,
1062            show_farm_access: false,
1063            farm_access_name_draft: String::new(),
1064            farm_access_discount_bps: 0,
1065            farm_access_index: 0,
1066            plant_quantity: 1,
1067            shop_tab: flatland_client_lib::ShopTab::default(),
1068            shop_menu_index: 0,
1069            shop_quantity: 1,
1070            shop_trade_log: std::collections::VecDeque::new(),
1071            show_npc_verb_menu: false,
1072            npc_verb_target: None,
1073            npc_verb_index: 0,
1074            npc_verb_notice: None,
1075            player_verbs: Default::default(),
1076            social_chat: Default::default(),
1077            trade_ui: Default::default(),
1078            whisper_pouch_ui: Default::default(),
1079            show_npc_chat: false,
1080            npc_chat: None,
1081            show_inventory_menu: false,
1082            inventory_menu_index: 0,
1083            inventory_tab: flatland_client_lib::InventoryTab::OnPerson,
1084            inventory_filter: String::new(),
1085            inventory_filter_focused: false,
1086            show_move_picker: false,
1087            show_rename_prompt: false,
1088            rename_plot_id: None,
1089            highlighted_plot_id: None,
1090            show_worker_rename: false,
1091            rename_buffer: String::new(),
1092            move_picker_index: 0,
1093            move_picker: None,
1094            show_grant_picker: false,
1095            grant_picker_index: 0,
1096            grant_picker: None,
1097            show_destroy_picker: false,
1098            destroy_confirm_pending: false,
1099            destroy_picker: None,
1100            combat_target: None,
1101            combat_target_label: None,
1102            ground_target: None,
1103            combat_fx: Vec::new(),
1104            ground_hazards: Vec::new(),
1105            in_combat: false,
1106            auto_attack: true,
1107            combat_has_los: false,
1108            attack_cd_ticks: 0,
1109            gcd_ticks: 0,
1110            weapon_ability_id: "unarmed".into(),
1111            mainhand_template_id: None,
1112            mainhand_label: None,
1113            mainhand_instance_id: None,
1114            offhand_template_id: None,
1115            offhand_label: None,
1116            offhand_instance_id: None,
1117            mainhand_hand_slots: 1,
1118            defense: None,
1119            worn: std::collections::BTreeMap::new(),
1120            carry_mass: 0.0,
1121            carry_mass_max: 0.0,
1122            encumbrance: flatland_protocol::EncumbranceState::Light,
1123            move_speed_mps: 0.0,
1124            move_speed_mult: 0.0,
1125            inventory_stacks: Vec::new(),
1126            keychain_stacks: Vec::new(),
1127            whisper_pouch_stacks: Vec::new(),
1128            combat_target_detail: None,
1129            statuses: Vec::new(),
1130            cast_progress: None,
1131            timed_channel: None,
1132            plot_build_offer: None,
1133            ability_cooldowns: Vec::new(),
1134            blocking_active: false,
1135            max_target_slots: 1,
1136            combat_slots: Vec::new(),
1137            rotation_presets: Vec::new(),
1138            known_abilities: Vec::new(),
1139            ability_meta: std::collections::HashMap::new(),
1140            ability_mastery: std::collections::HashMap::new(),
1141            hotbar: vec![None; 9],
1142            max_abilities_per_rotation: 0,
1143            show_loadout_menu: false,
1144            show_keychain_menu: false,
1145            keychain_menu_index: 0,
1146            show_rotation_editor: false,
1147            loadout_menu_index: 0,
1148            loadout_hotbar_slot: 1,
1149            loadout_ability_index: 0,
1150            loadout_focus_presets: false,
1151            rotation_editor: Default::default(),
1152            harvest_in_progress: false,
1153            harvest_started_at: None,
1154            pending_craft_ack: None,
1155            craft_channel_blueprint_id: None,
1156            pending_worker_job_ack: None,
1157            attending_worker_instance_id: None,
1158            quest_log: Vec::new(),
1159            interactables: Vec::new(),
1160            show_quest_offer: false,
1161            pending_quest_offers: Vec::new(),
1162            quest_offer_index: 0,
1163            show_quest_menu: false,
1164            quest_menu_index: 0,
1165            quest_withdraw_confirm: false,
1166            hired_workers: Vec::new(),
1167            show_workers_menu: false,
1168            workers_menu_index: 0,
1169            worker_dismiss_confirmation: None,
1170            workers_menu_compact: false,
1171            worker_step_display: std::collections::BTreeMap::new(),
1172            worker_error_display: std::collections::BTreeMap::new(),
1173            worker_health_ring_until: std::collections::BTreeMap::new(),
1174            pending_worker_hire_since: None,
1175            show_worker_give_picker: false,
1176            worker_give_picker_index: 0,
1177            worker_give_picker: None,
1178            show_worker_give_target_picker: false,
1179            worker_give_target_picker_index: 0,
1180            worker_give_target_picker: None,
1181            show_worker_take_picker: false,
1182            worker_take_picker_index: 0,
1183            worker_take_picker: None,
1184            show_worker_teach_picker: false,
1185            worker_teach_picker_index: 0,
1186            worker_teach_picker: None,
1187            worker_route_editor: None,
1188            progression_curve: None,
1189        }
1190    }
1191
1192    #[test]
1193    fn shallow_water_terrain_paints_tilde() {
1194        use flatland_protocol::TerrainZoneView;
1195        let mut state = state_with_building(BuildingView {
1196            id: "x".into(),
1197            label: "X".into(),
1198            x: 128.0,
1199            y: 128.0,
1200            width_m: 1.0,
1201            depth_m: 1.0,
1202            interior_blueprint: None,
1203            tags: vec![],
1204            market_boundary_zone_ids: vec![],
1205            market_max_volume: None,
1206            wall_set: None,
1207            roof_set: None,
1208        });
1209        state.terrain_zones.push(TerrainZoneView {
1210            id: "pond".into(),
1211            x0: 126.0,
1212            y0: 126.0,
1213            x1: 130.0,
1214            y1: 130.0,
1215            kind: TerrainKindView::ShallowWater,
1216            elevation: -0.5,
1217            glyph: None,
1218            color: None,
1219            tile_id: None,
1220            z_order: 0,
1221            channel_start_tick: None,
1222            channel_end_tick: None,
1223        });
1224        state.entities = vec![EntityState {
1225            id: 1,
1226            label: "You".into(),
1227            transform: Transform {
1228                position: WorldCoord::surface(128.0, 128.0),
1229                yaw: 0.0,
1230                velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
1231            },
1232            vitals: Some(PlayerVitals::default()),
1233            attributes: Some(PrimaryAttributes::default()),
1234            skills: Some(flatland_protocol::PlayerSkills::default()),
1235            inside_building: None,
1236            tile_id: None,
1237            paperdoll_ref: None,
1238            draw_scale: 1.0,
1239            presentation_state: None,
1240            sprite_mode: None,
1241            progression_xp: None,
1242            combat_cues: Vec::new(),
1243            statuses: Vec::new(),
1244        }];
1245        state.player = state.entities.first().cloned();
1246        let view = WorldView::build_with_target(&state, 9, 9, None);
1247        let flat = view.cells.join("");
1248        let water = map_presentation::shallow_water_presentation();
1249        assert!(
1250            flat.contains(&water.glyph),
1251            "expected water tiles ({:?}): {flat}",
1252            water.glyph
1253        );
1254        assert!(
1255            view.cell_fg.iter().any(|c| *c == Some(water.color)),
1256            "expected water color on terrain cells"
1257        );
1258    }
1259
1260    #[test]
1261    fn zone_glyph_and_color_overrides_paint_on_map() {
1262        use flatland_protocol::TerrainZoneView;
1263        let mut state = state_with_building(BuildingView {
1264            id: "x".into(),
1265            label: "X".into(),
1266            x: 128.0,
1267            y: 128.0,
1268            width_m: 1.0,
1269            depth_m: 1.0,
1270            interior_blueprint: None,
1271            tags: vec![],
1272            market_boundary_zone_ids: vec![],
1273            market_max_volume: None,
1274            wall_set: None,
1275            roof_set: None,
1276        });
1277        state.terrain_zones.push(TerrainZoneView {
1278            id: "marked".into(),
1279            x0: 126.0,
1280            y0: 126.0,
1281            x1: 130.0,
1282            y1: 130.0,
1283            kind: TerrainKindView::Grass,
1284            elevation: 0.0,
1285            glyph: Some("%".into()),
1286            color: Some("magenta".into()),
1287            tile_id: None,
1288            z_order: 0,
1289            channel_start_tick: None,
1290            channel_end_tick: None,
1291        });
1292        state.entities = vec![EntityState {
1293            id: 1,
1294            label: "You".into(),
1295            transform: Transform {
1296                position: WorldCoord::surface(128.0, 128.0),
1297                yaw: 0.0,
1298                velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
1299            },
1300            vitals: Some(PlayerVitals::default()),
1301            attributes: Some(PrimaryAttributes::default()),
1302            skills: Some(flatland_protocol::PlayerSkills::default()),
1303            inside_building: None,
1304            tile_id: None,
1305            paperdoll_ref: None,
1306            draw_scale: 1.0,
1307            presentation_state: None,
1308            sprite_mode: None,
1309            progression_xp: None,
1310            combat_cues: Vec::new(),
1311            statuses: Vec::new(),
1312        }];
1313        state.player = state.entities.first().cloned();
1314        let view = WorldView::build_with_target(&state, 9, 9, None);
1315        let flat = view.cells.join("");
1316        assert!(
1317            flat.contains('%'),
1318            "expected custom zone glyph on map: {flat}"
1319        );
1320        assert!(
1321            view.cell_fg.iter().any(|c| *c == Some(RgbColor::MAGENTA)),
1322            "expected custom zone color on terrain cells"
1323        );
1324    }
1325
1326    #[test]
1327    fn well_paints_water_ring_and_center() {
1328        let building = BuildingView {
1329            id: "town_well".into(),
1330            label: "Well".into(),
1331            x: 122.0,
1332            y: 106.0,
1333            width_m: 3.0,
1334            depth_m: 3.0,
1335            interior_blueprint: None,
1336            tags: vec!["well".into()],
1337            market_boundary_zone_ids: vec![],
1338            market_max_volume: None,
1339            wall_set: None,
1340            roof_set: None,
1341        };
1342        let mut state = state_with_building(building);
1343        state.entities = vec![EntityState {
1344            id: 1,
1345            label: "You".into(),
1346            transform: Transform {
1347                position: WorldCoord::surface(120.0, 106.0),
1348                yaw: 0.0,
1349                velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
1350            },
1351            vitals: Some(PlayerVitals::default()),
1352            attributes: Some(PrimaryAttributes::default()),
1353            skills: Some(flatland_protocol::PlayerSkills::default()),
1354            inside_building: None,
1355            tile_id: None,
1356            paperdoll_ref: None,
1357            draw_scale: 1.0,
1358            presentation_state: None,
1359            sprite_mode: None,
1360            progression_xp: None,
1361            combat_cues: Vec::new(),
1362            statuses: Vec::new(),
1363        }];
1364        state.player = state.entities.first().cloned();
1365        let view = WorldView::build_with_target(&state, 9, 9, None);
1366        let flat = view.cells.join("");
1367        assert!(flat.contains('~'), "expected well water: {flat}");
1368        assert!(flat.contains('O'), "expected well center: {flat}");
1369    }
1370
1371    #[test]
1372    fn broker_hut_draws_wall_outline() {
1373        let building = BuildingView {
1374            id: "broker_hut".into(),
1375            label: "Broker's Hut".into(),
1376            x: 148.0,
1377            y: 118.0,
1378            width_m: 8.0,
1379            depth_m: 6.0,
1380            interior_blueprint: None,
1381            tags: vec![],
1382            market_boundary_zone_ids: vec![],
1383            market_max_volume: None,
1384            wall_set: None,
1385            roof_set: None,
1386        };
1387        let mut state = state_with_building(building);
1388        state.player = state.entities.first().cloned();
1389        let view = WorldView::build_with_target(&state, 25, 15, None);
1390        let flat = view.cells.join("");
1391        assert!(flat.contains('+'), "expected corners: {flat}");
1392        assert!(flat.contains('-'), "expected horiz walls: {flat}");
1393        assert!(flat.contains('|'), "expected vert walls: {flat}");
1394    }
1395
1396    #[test]
1397    fn interior_map_renders_rooms_and_walls() {
1398        use flatland_protocol::{InteriorMapView, InteriorRoomView};
1399        let building = BuildingView {
1400            id: "broker_hut".into(),
1401            label: "Broker's Hut".into(),
1402            x: 148.0,
1403            y: 118.0,
1404            width_m: 8.0,
1405            depth_m: 6.0,
1406            interior_blueprint: Some("broker_hut".into()),
1407            tags: vec![],
1408            market_boundary_zone_ids: vec![],
1409            market_max_volume: None,
1410            wall_set: None,
1411            roof_set: None,
1412        };
1413        let mut state = state_with_building(building);
1414        state.interior_map = Some(InteriorMapView {
1415            building_id: "broker_hut".into(),
1416            blueprint_id: "broker_hut".into(),
1417            background_color: "#000000".into(),
1418            default_floor_color: Some("#2a2a2a".into()),
1419            floor_height_m: 3.0,
1420            z_platforms: vec![],
1421            z_transitions: vec![],
1422            rooms: vec![InteriorRoomView {
1423                id: "main".into(),
1424                label: "Main".into(),
1425                floor: 0,
1426                x0: 0.0,
1427                y0: 0.0,
1428                x1: 8.5,
1429                y1: 7.0,
1430                floor_color: None,
1431                floor_glyph: None,
1432            }],
1433            room_doors: vec![],
1434        });
1435        state.entities = vec![EntityState {
1436            id: 1,
1437            label: "You".into(),
1438            transform: Transform {
1439                position: WorldCoord::surface(4.0, 3.0),
1440                yaw: 0.0,
1441                velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
1442            },
1443            vitals: Some(PlayerVitals::default()),
1444            attributes: Some(PrimaryAttributes::default()),
1445            skills: Some(flatland_protocol::PlayerSkills::default()),
1446            inside_building: Some("broker_hut".into()),
1447            tile_id: None,
1448            paperdoll_ref: None,
1449            draw_scale: 1.0,
1450            presentation_state: None,
1451            sprite_mode: None,
1452            progression_xp: None,
1453            combat_cues: Vec::new(),
1454            statuses: Vec::new(),
1455        }];
1456        state.player = state.entities.first().cloned();
1457        let view = WorldView::build_with_target(&state, 25, 15, None);
1458        assert_eq!(view.inside_building.as_deref(), Some("broker_hut"));
1459        let flat = view.cells.join("");
1460        assert!(flat.contains('+'), "expected interior walls: {flat}");
1461        assert!(flat.contains('@'), "expected player marker: {flat}");
1462    }
1463
1464    #[test]
1465    fn stale_interior_map_renders_outdoor_when_outside() {
1466        use flatland_protocol::{InteriorMapView, InteriorRoomView};
1467        let building = BuildingView {
1468            id: "town_hall".into(),
1469            label: "Town Hall".into(),
1470            x: 163.0,
1471            y: 137.0,
1472            width_m: 20.0,
1473            depth_m: 10.0,
1474            interior_blueprint: Some("town_hall".into()),
1475            tags: vec![],
1476            market_boundary_zone_ids: vec![],
1477            market_max_volume: None,
1478            wall_set: None,
1479            roof_set: None,
1480        };
1481        let mut state = state_with_building(building);
1482        state.interior_map = Some(InteriorMapView {
1483            building_id: "town_hall".into(),
1484            blueprint_id: "town_hall".into(),
1485            background_color: "#000000".into(),
1486            default_floor_color: Some("#2a2a2a".into()),
1487            floor_height_m: 3.0,
1488            z_platforms: vec![],
1489            z_transitions: vec![],
1490            rooms: vec![InteriorRoomView {
1491                id: "main_hall".into(),
1492                label: "Main".into(),
1493                floor: 0,
1494                x0: -3.5,
1495                y0: -8.0,
1496                x1: 18.5,
1497                y1: 6.0,
1498                floor_color: None,
1499                floor_glyph: None,
1500            }],
1501            room_doors: vec![],
1502        });
1503        state.player = state.entities.first().cloned();
1504        let view = WorldView::build_with_target(&state, 25, 15, None);
1505        assert!(view.inside_building.is_none());
1506        let flat = view.cells.join("");
1507        let grass = map_presentation::terrain_for(TerrainKindView::Grass).glyph;
1508        assert!(
1509            flat.contains(&grass),
1510            "expected outdoor terrain, not stale interior background: {flat}"
1511        );
1512        assert!(
1513            !flat.chars().all(|c| c == ' ' || c == '@'),
1514            "stale interior_map must not paint black interior when outside"
1515        );
1516    }
1517
1518    #[test]
1519    fn stale_inside_flag_still_renders_outdoor_world() {
1520        use flatland_protocol::ResourceNodeState;
1521        let building = BuildingView {
1522            id: "broker_hut".into(),
1523            label: "Broker's Hut".into(),
1524            x: 148.0,
1525            y: 118.0,
1526            width_m: 8.0,
1527            depth_m: 6.0,
1528            interior_blueprint: None,
1529            tags: vec![],
1530            market_boundary_zone_ids: vec![],
1531            market_max_volume: None,
1532            wall_set: None,
1533            roof_set: None,
1534        };
1535        let mut state = state_with_building(building);
1536        state.entities = vec![EntityState {
1537            id: 1,
1538            label: "You".into(),
1539            transform: Transform {
1540                position: WorldCoord::surface(128.0, 128.0),
1541                yaw: 0.0,
1542                velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
1543            },
1544            vitals: Some(PlayerVitals::default()),
1545            attributes: Some(PrimaryAttributes::default()),
1546            skills: Some(flatland_protocol::PlayerSkills::default()),
1547            inside_building: Some("broker_hut".into()),
1548            tile_id: None,
1549            paperdoll_ref: None,
1550            draw_scale: 1.0,
1551            presentation_state: None,
1552            sprite_mode: None,
1553            progression_xp: None,
1554            combat_cues: Vec::new(),
1555            statuses: Vec::new(),
1556        }];
1557        state.player = state.entities.first().cloned();
1558        state
1559            .resource_nodes
1560            .push(flatland_protocol::ResourceNodeView {
1561                id: "oak".into(),
1562                label: "Oak".into(),
1563                x: 126.0,
1564                y: 134.0,
1565                z: 0.0,
1566                item_template: "oak_log".into(),
1567                state: ResourceNodeState::Available,
1568                blocking: true,
1569                blocking_radius_m: 0.8,
1570                harvest_off: false,
1571                tile_id: None,
1572                yaw: 0.0,
1573                pitch: 0.0,
1574                roll: 0.0,
1575                draw_scale: 1.0,
1576                sprite_mode: None,
1577                growth_progress: None,
1578                presentation_state: None,
1579                channel_start_tick: None,
1580                channel_end_tick: None,
1581                harvest_drop_templates: Vec::new(),
1582            });
1583        let view = WorldView::build_with_target(&state, 25, 15, None);
1584        assert_eq!(
1585            view.inside_building.as_deref(),
1586            Some("broker_hut"),
1587            "server inside flag is authoritative"
1588        );
1589        let flat = view.cells.join("");
1590        let oak = map_presentation::resource_for(&flatland_protocol::ResourceNodeView {
1591            id: "oak".into(),
1592            label: "Oak".into(),
1593            x: 0.0,
1594            y: 0.0,
1595            z: 0.0,
1596            item_template: "oak_log".into(),
1597            state: flatland_protocol::ResourceNodeState::Available,
1598            blocking: true,
1599            blocking_radius_m: 0.8,
1600            harvest_off: false,
1601            tile_id: None,
1602            yaw: 0.0,
1603            pitch: 0.0,
1604            roll: 0.0,
1605            draw_scale: 1.0,
1606            sprite_mode: None,
1607            growth_progress: None,
1608            presentation_state: None,
1609            channel_start_tick: None,
1610            channel_end_tick: None,
1611            harvest_drop_templates: Vec::new(),
1612        });
1613        assert!(
1614            flat.contains(&oak.glyph),
1615            "expected nearby tree ({:?}): {flat}",
1616            oak.glyph
1617        );
1618        assert!(flat.contains('@'), "expected player: {flat}");
1619    }
1620
1621    #[test]
1622    fn inside_building_flag_selects_active_instance() {
1623        let town = BuildingView {
1624            id: "town_hall".into(),
1625            label: "Town Hall".into(),
1626            x: 160.0,
1627            y: 136.0,
1628            width_m: 20.0,
1629            depth_m: 10.0,
1630            interior_blueprint: Some("town_hall".into()),
1631            tags: vec![],
1632            market_boundary_zone_ids: vec![],
1633            market_max_volume: None,
1634            wall_set: None,
1635            roof_set: None,
1636        };
1637        let guild = BuildingView {
1638            id: "guild_hall".into(),
1639            label: "Guild Hall".into(),
1640            x: 164.0,
1641            y: 152.0,
1642            width_m: 20.0,
1643            depth_m: 14.0,
1644            interior_blueprint: Some("guild_hall".into()),
1645            tags: vec![],
1646            market_boundary_zone_ids: vec![],
1647            market_max_volume: None,
1648            wall_set: None,
1649            roof_set: None,
1650        };
1651        let mut state = state_with_building(town);
1652        state.buildings.push(guild);
1653        state.entities = vec![EntityState {
1654            id: 1,
1655            label: "You".into(),
1656            transform: Transform {
1657                position: WorldCoord::surface(4.0, 3.0),
1658                yaw: 0.0,
1659                velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
1660            },
1661            vitals: Some(PlayerVitals::default()),
1662            attributes: Some(PrimaryAttributes::default()),
1663            skills: Some(flatland_protocol::PlayerSkills::default()),
1664            inside_building: Some("guild_hall".into()),
1665            tile_id: None,
1666            paperdoll_ref: None,
1667            draw_scale: 1.0,
1668            presentation_state: None,
1669            sprite_mode: None,
1670            progression_xp: None,
1671            combat_cues: Vec::new(),
1672            statuses: Vec::new(),
1673        }];
1674        state.player = state.entities.first().cloned();
1675        state.world_width_m = 256.0;
1676        state.world_height_m = 256.0;
1677
1678        let view = WorldView::build_with_target(&state, 25, 15, None);
1679        assert_eq!(view.inside_building.as_deref(), Some("guild_hall"));
1680    }
1681
1682    #[test]
1683    fn combat_target_marks_creature_cell() {
1684        let building = BuildingView {
1685            id: "x".into(),
1686            label: "X".into(),
1687            x: 128.0,
1688            y: 128.0,
1689            width_m: 1.0,
1690            depth_m: 1.0,
1691            interior_blueprint: None,
1692            tags: vec![],
1693            market_boundary_zone_ids: vec![],
1694            market_max_volume: None,
1695            wall_set: None,
1696            roof_set: None,
1697        };
1698        let mut state = state_with_building(building);
1699        state.entities = vec![
1700            EntityState {
1701                id: 1,
1702                label: "You".into(),
1703                transform: Transform {
1704                    position: WorldCoord::surface(100.0, 100.0),
1705                    yaw: 0.0,
1706                    velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
1707                },
1708                vitals: Some(PlayerVitals::default()),
1709                attributes: Some(PrimaryAttributes::default()),
1710                skills: Some(flatland_protocol::PlayerSkills::default()),
1711                inside_building: None,
1712                tile_id: None,
1713                paperdoll_ref: None,
1714                draw_scale: 1.0,
1715                presentation_state: None,
1716                sprite_mode: None,
1717                progression_xp: None,
1718                combat_cues: Vec::new(),
1719                statuses: Vec::new(),
1720            },
1721            EntityState {
1722                id: 42,
1723                label: "Rabbit".into(),
1724                transform: Transform {
1725                    position: WorldCoord::surface(103.0, 100.0),
1726                    yaw: 0.0,
1727                    velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
1728                },
1729                vitals: None,
1730                attributes: None,
1731                skills: None,
1732                inside_building: None,
1733                tile_id: None,
1734                paperdoll_ref: None,
1735                draw_scale: 1.0,
1736                presentation_state: None,
1737                sprite_mode: None,
1738                progression_xp: None,
1739                combat_cues: Vec::new(),
1740                statuses: Vec::new(),
1741            },
1742        ];
1743        state.player = state.entities.first().cloned();
1744        state.combat_target = Some(42);
1745        state.combat_target_label = Some("Rabbit".into());
1746
1747        let view = WorldView::build_with_target(&state, 25, 15, None);
1748        let marked: usize = view
1749            .target_t1_cells
1750            .iter()
1751            .chain(view.target_t2_cells.iter())
1752            .filter(|b| **b)
1753            .count();
1754        assert_eq!(marked, 1, "exactly one targeted cell");
1755        let idx = view
1756            .target_t1_cells
1757            .iter()
1758            .chain(view.target_t2_cells.iter())
1759            .position(|b| *b)
1760            .expect("target cell");
1761        assert_eq!(view.cells[idx], "R");
1762    }
1763
1764    #[test]
1765    fn combat_target_ring_prefers_live_npc_coords() {
1766        let building = BuildingView {
1767            id: "x".into(),
1768            label: "X".into(),
1769            x: 128.0,
1770            y: 128.0,
1771            width_m: 1.0,
1772            depth_m: 1.0,
1773            interior_blueprint: None,
1774            tags: vec![],
1775            market_boundary_zone_ids: vec![],
1776            market_max_volume: None,
1777            wall_set: None,
1778            roof_set: None,
1779        };
1780        let mut state = state_with_building(building);
1781        state.entities = vec![
1782            EntityState {
1783                id: 1,
1784                label: "You".into(),
1785                transform: Transform {
1786                    position: WorldCoord::surface(100.0, 100.0),
1787                    yaw: 0.0,
1788                    velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
1789                },
1790                vitals: Some(PlayerVitals::default()),
1791                attributes: Some(PrimaryAttributes::default()),
1792                skills: Some(flatland_protocol::PlayerSkills::default()),
1793                inside_building: None,
1794                tile_id: None,
1795                paperdoll_ref: None,
1796                draw_scale: 1.0,
1797                presentation_state: None,
1798                sprite_mode: None,
1799                progression_xp: None,
1800                combat_cues: Vec::new(),
1801                statuses: Vec::new(),
1802            },
1803            EntityState {
1804                id: 42,
1805                label: "Rabbit".into(),
1806                // Stale entity transform — NPC view has the live position.
1807                transform: Transform {
1808                    position: WorldCoord::surface(90.0, 100.0),
1809                    yaw: 0.0,
1810                    velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
1811                },
1812                vitals: None,
1813                attributes: None,
1814                skills: None,
1815                inside_building: None,
1816                tile_id: None,
1817                paperdoll_ref: None,
1818                draw_scale: 1.0,
1819                presentation_state: None,
1820                sprite_mode: None,
1821                progression_xp: None,
1822                combat_cues: Vec::new(),
1823                statuses: Vec::new(),
1824            },
1825        ];
1826        state.npcs = vec![flatland_protocol::NpcView {
1827            id: "rabbit-1".into(),
1828            label: "Rabbit".into(),
1829            role: "wildlife".into(),
1830            x: 103.0,
1831            y: 100.0,
1832            building_id: None,
1833            entity_id: Some(42),
1834            life_state: Some(flatland_protocol::LifeState::Alive),
1835            hp_pct: Some(1.0),
1836            can_trade: false,
1837            buy_templates: Vec::new(),
1838            tile_id: None,
1839            behavior_state: None,
1840            presentation_state: None,
1841            sprite_mode: None,
1842            paperdoll_ref: None,
1843            draw_scale: 1.0,
1844            yaw: None,
1845            perception_fov_deg: None,
1846            perception_sight_m: None,
1847            perception_hear_m: None,
1848            quest_verbs: Vec::new(),
1849        }];
1850        state.player = state.entities.first().cloned();
1851        state.combat_target = Some(42);
1852
1853        let view = WorldView::build_with_target(&state, 25, 15, None);
1854        let marked = view
1855            .target_t1_cells
1856            .iter()
1857            .position(|b| *b)
1858            .expect("target cell");
1859        let half_w = (view.width / 2) as i32;
1860        let half_h = (view.height / 2) as i32;
1861        let (live_gx, live_gy) = world_to_grid(
1862            103.0,
1863            100.0,
1864            100.0,
1865            100.0,
1866            half_w,
1867            half_h,
1868            view.width,
1869            view.height,
1870        )
1871        .expect("live npc in view");
1872        let (stale_gx, stale_gy) = world_to_grid(
1873            90.0,
1874            100.0,
1875            100.0,
1876            100.0,
1877            half_w,
1878            half_h,
1879            view.width,
1880            view.height,
1881        )
1882        .expect("stale entity in view");
1883        let live_idx = live_gy * view.width + live_gx;
1884        let stale_idx = stale_gy * view.width + stale_gx;
1885        assert_eq!(marked, live_idx, "ring must follow live NPC coords");
1886        assert_ne!(
1887            marked, stale_idx,
1888            "ring must not stay on stale entity coords"
1889        );
1890    }
1891
1892    #[test]
1893    fn grid_to_world_roundtrips_center() {
1894        let px = 10.0;
1895        let py = 20.0;
1896        let view_w = 11;
1897        let view_h = 11;
1898        let half_w = (view_w / 2) as i32;
1899        let half_h = (view_h / 2) as i32;
1900        let (gx, gy) =
1901            world_to_grid(12.0, 18.0, px, py, half_w, half_h, view_w, view_h).expect("in view");
1902        let (wx, wy) = grid_to_world(gx, gy, px, py, view_w, view_h).expect("inverse");
1903        assert!((wx - 12.0).abs() < 0.01);
1904        assert!((wy - 18.0).abs() < 0.01);
1905    }
1906
1907    #[test]
1908    fn vertical_axis_quantizes_to_one_meter() {
1909        let px = 0.0;
1910        let py = 0.0;
1911        let view_w = 21;
1912        let view_h = 21;
1913        let half_w = (view_w / 2) as i32;
1914        let half_h = (view_h / 2) as i32;
1915        let (gx, gy) =
1916            world_to_grid(3.0, 3.0, px, py, half_w, half_h, view_w, view_h).expect("in view");
1917        let (wx, wy) = grid_to_world(gx, gy, px, py, view_w, view_h).expect("inverse");
1918        assert!(
1919            (wx - 3.0).abs() < 0.01,
1920            "x should stay exact to 1m: got {wx}"
1921        );
1922        assert!(
1923            (wy - 3.0).abs() < 0.01,
1924            "y should stay exact to 1m: got {wy}"
1925        );
1926    }
1927
1928    #[test]
1929    fn square_extent_spans_equal_rows_and_columns() {
1930        let px = 0.0;
1931        let py = 0.0;
1932        let half_w = 50;
1933        let half_h = 50;
1934        let width = 101;
1935        let height = 101;
1936        let (gx0, gy0) =
1937            world_to_grid(-4.0, -4.0, px, py, half_w, half_h, width, height).expect("in view");
1938        let (gx1, gy1) =
1939            world_to_grid(4.0, 4.0, px, py, half_w, half_h, width, height).expect("in view");
1940        let cols_spanned = (gx1 as i32 - gx0 as i32).unsigned_abs();
1941        let rows_spanned = (gy1 as i32 - gy0 as i32).unsigned_abs();
1942        assert_eq!(cols_spanned, 8, "8m wide should span 8 columns");
1943        assert_eq!(rows_spanned, 8, "8m tall should span 8 rows");
1944    }
1945
1946    #[test]
1947    fn resource_paints_on_terrain_cell_for_same_world_coords() {
1948        use flatland_protocol::{ResourceNodeState, ResourceNodeView, TerrainZoneView};
1949
1950        let px = 128.0;
1951        let py = 128.0;
1952        let rx = 131.0;
1953        let ry = 132.0;
1954        let mut state = state_with_building(BuildingView {
1955            id: "x".into(),
1956            label: "X".into(),
1957            x: 128.0,
1958            y: 128.0,
1959            width_m: 1.0,
1960            depth_m: 1.0,
1961            interior_blueprint: None,
1962            tags: vec![],
1963            market_boundary_zone_ids: vec![],
1964            market_max_volume: None,
1965            wall_set: None,
1966            roof_set: None,
1967        });
1968        state.terrain_zones.push(TerrainZoneView {
1969            id: "pond".into(),
1970            x0: rx,
1971            y0: ry,
1972            x1: rx + 1.0,
1973            y1: ry + 1.0,
1974            kind: TerrainKindView::ShallowWater,
1975            elevation: -0.5,
1976            glyph: None,
1977            color: None,
1978            tile_id: None,
1979            z_order: 0,
1980            channel_start_tick: None,
1981            channel_end_tick: None,
1982        });
1983        state.resource_nodes.push(ResourceNodeView {
1984            id: "oak".into(),
1985            label: "Oak".into(),
1986            x: rx,
1987            y: ry,
1988            z: 0.0,
1989            item_template: "oak_log".into(),
1990            state: ResourceNodeState::Available,
1991            blocking: true,
1992            blocking_radius_m: 0.8,
1993            harvest_off: false,
1994            tile_id: None,
1995            yaw: 0.0,
1996            pitch: 0.0,
1997            roll: 0.0,
1998            draw_scale: 1.0,
1999            sprite_mode: None,
2000            growth_progress: None,
2001            presentation_state: None,
2002            channel_start_tick: None,
2003            channel_end_tick: None,
2004            harvest_drop_templates: Vec::new(),
2005        });
2006        state.entities[0].transform.position = WorldCoord::surface(px, py);
2007        state.player = state.entities.first().cloned();
2008
2009        let view = WorldView::build_with_target(&state, 25, 15, None);
2010        let half_w = (view.width / 2) as i32;
2011        let half_h = (view.height / 2) as i32;
2012        let (gx, gy) = world_to_grid(rx, ry, px, py, half_w, half_h, view.width, view.height)
2013            .expect("resource in view");
2014        let idx = gy * view.width + gx;
2015        let oak = map_presentation::resource_for(&state.resource_nodes[0]);
2016        assert_eq!(
2017            view.cells[idx], oak.glyph,
2018            "resource should paint on the grid cell for its world coords"
2019        );
2020        let (wx, wy) = grid_to_world(gx, gy, px, py, view.width, view.height).expect("inverse");
2021        assert!(
2022            (wx - rx).abs() < 0.01 && (wy - ry).abs() < 0.01,
2023            "resource grid cell should sample terrain at ({wx}, {wy}), expected ({rx}, {ry})"
2024        );
2025    }
2026
2027    #[test]
2028    fn chest_and_loot_paint_on_non_grass_terrain() {
2029        use flatland_protocol::{GroundDropView, PlacedContainerView, TerrainZoneView};
2030
2031        let px = 50.0;
2032        let py = 50.0;
2033        let cx = 53.0;
2034        let cy = 52.0;
2035        let lx = 54.0;
2036        let ly = 52.0;
2037        let mut state = state_with_building(BuildingView {
2038            id: "x".into(),
2039            label: "X".into(),
2040            x: 50.0,
2041            y: 50.0,
2042            width_m: 1.0,
2043            depth_m: 1.0,
2044            interior_blueprint: None,
2045            tags: vec![],
2046            market_boundary_zone_ids: vec![],
2047            market_max_volume: None,
2048            wall_set: None,
2049            roof_set: None,
2050        });
2051        state.terrain_zones.push(TerrainZoneView {
2052            id: "trail".into(),
2053            x0: 52.0,
2054            y0: 51.0,
2055            x1: 56.0,
2056            y1: 54.0,
2057            kind: TerrainKindView::Trail,
2058            elevation: 0.0,
2059            glyph: None,
2060            color: None,
2061            tile_id: None,
2062            z_order: 0,
2063            channel_start_tick: None,
2064            channel_end_tick: None,
2065        });
2066        state.placed_containers.push(PlacedContainerView {
2067            id: "chest_1".into(),
2068            template_id: "wood_chest".into(),
2069            display_name: "Storage".into(),
2070            x: cx,
2071            y: cy,
2072            z: 0.0,
2073            locked: false,
2074            accessible: true,
2075            owner_character_id: None,
2076            contents: vec![],
2077            lock_id: None,
2078            capacity_volume: Some(40.0),
2079            item_instance_id: None,
2080            tile_id: None,
2081            worker_lodging_capacity: None,
2082            blocking: true,
2083            blocking_radius_m: 0.8,
2084            building_id: None,
2085        });
2086        state.ground_drops.push(GroundDropView {
2087            id: "drop_1".into(),
2088            template_id: "lumber".into(),
2089            quantity: 2,
2090            x: lx,
2091            y: ly,
2092            z: 0.0,
2093            tile_id: None,
2094            display_name: None,
2095            yaw: 0.0,
2096            pitch: 0.0,
2097            roll: 0.0,
2098            draw_scale: 1.0,
2099            item_instance_id: None,
2100            props: Default::default(),
2101            status_bindings: Vec::new(),
2102        });
2103        state.entities[0].transform.position = WorldCoord::surface(px, py);
2104        state.player = state.entities.first().cloned();
2105
2106        let view = WorldView::build_with_target(&state, 25, 15, None);
2107        let half_w = (view.width / 2) as i32;
2108        let half_h = (view.height / 2) as i32;
2109        let (gx, gy) =
2110            world_to_grid(cx, cy, px, py, half_w, half_h, view.width, view.height).expect("chest");
2111        let chest = map_presentation::chest_presentation(false);
2112        assert_eq!(
2113            view.cells[gy * view.width + gx],
2114            chest.glyph,
2115            "chest must paint on trail/non-grass cells"
2116        );
2117        let (gx2, gy2) =
2118            world_to_grid(lx, ly, px, py, half_w, half_h, view.width, view.height).expect("loot");
2119        let loot = map_presentation::loot_presentation();
2120        assert_eq!(
2121            view.cells[gy2 * view.width + gx2],
2122            loot.glyph,
2123            "ground loot must paint on trail/non-grass cells"
2124        );
2125    }
2126
2127    #[test]
2128    fn build_with_anchor_sets_view_origin() {
2129        let mut state = state_with_building(BuildingView {
2130            id: "x".into(),
2131            label: "X".into(),
2132            x: 128.0,
2133            y: 128.0,
2134            width_m: 1.0,
2135            depth_m: 1.0,
2136            interior_blueprint: None,
2137            tags: vec![],
2138            market_boundary_zone_ids: vec![],
2139            market_max_volume: None,
2140            wall_set: None,
2141            roof_set: None,
2142        });
2143        state.entities[0].transform.position = WorldCoord::surface(100.0, 200.0);
2144        state.player = state.entities.first().cloned();
2145        let view = WorldView::build_with_anchor(
2146            &state,
2147            21,
2148            15,
2149            12.3,
2150            40.7,
2151            None,
2152            WorldViewOptions::terrain_only(),
2153        );
2154        assert!((view.origin_x - 12.3).abs() < 0.01);
2155        assert!((view.origin_y - 40.7).abs() < 0.01);
2156    }
2157
2158    #[test]
2159    fn skip_local_player_glyph_when_paint_local_player_false() {
2160        let mut state = state_with_building(BuildingView {
2161            id: "x".into(),
2162            label: "X".into(),
2163            x: 10.0,
2164            y: 10.0,
2165            width_m: 1.0,
2166            depth_m: 1.0,
2167            interior_blueprint: None,
2168            tags: vec![],
2169            market_boundary_zone_ids: vec![],
2170            market_max_volume: None,
2171            wall_set: None,
2172            roof_set: None,
2173        });
2174        state.entities[0].transform.position = WorldCoord::surface(128.0, 128.0);
2175        state.player = state.entities.first().cloned();
2176        let player_glyph = map_presentation::player_presentation().glyph;
2177
2178        let with_player =
2179            WorldView::build_with_options(&state, 21, 15, None, WorldViewOptions::default());
2180        let cx = with_player.width / 2;
2181        let cy = with_player.height / 2;
2182        assert_eq!(
2183            with_player.cells[cy * with_player.width + cx],
2184            player_glyph,
2185            "default build paints @"
2186        );
2187
2188        let without =
2189            WorldView::build_with_options(&state, 21, 15, None, WorldViewOptions::terrain_only());
2190        assert_ne!(
2191            without.cells[cy * without.width + cx],
2192            player_glyph,
2193            "gfx sprite mode must not paint local player @"
2194        );
2195    }
2196
2197    fn town_hall_state(px: f32, py: f32) -> GameState {
2198        use flatland_protocol::{InteriorMapView, InteriorRoomView};
2199        let mut state = state_with_building(BuildingView {
2200            id: "town_hall".into(),
2201            label: "Town Hall".into(),
2202            x: 156.0,
2203            y: 153.0,
2204            width_m: 20.0,
2205            depth_m: 9.0,
2206            interior_blueprint: Some("town_hall".into()),
2207            tags: vec![],
2208            market_boundary_zone_ids: vec![],
2209            market_max_volume: None,
2210            wall_set: None,
2211            roof_set: None,
2212        });
2213        state.interior_map = Some(InteriorMapView {
2214            building_id: "town_hall".into(),
2215            blueprint_id: "town_hall".into(),
2216            background_color: "#000".into(),
2217            default_floor_color: Some("#2a2a2a".into()),
2218            floor_height_m: 3.0,
2219            z_platforms: vec![],
2220            z_transitions: vec![],
2221            rooms: vec![
2222                InteriorRoomView {
2223                    id: "main".into(),
2224                    label: "Main".into(),
2225                    floor: 0,
2226                    x0: -3.5,
2227                    y0: -8.0,
2228                    x1: 18.5,
2229                    y1: 6.0,
2230                    floor_color: None,
2231                    floor_glyph: None,
2232                },
2233                InteriorRoomView {
2234                    id: "kitchen".into(),
2235                    label: "Kitchen".into(),
2236                    floor: 0,
2237                    x0: 18.5,
2238                    y0: -8.0,
2239                    x1: 23.5,
2240                    y1: 0.0,
2241                    floor_color: None,
2242                    floor_glyph: None,
2243                },
2244                InteriorRoomView {
2245                    id: "weapon".into(),
2246                    label: "Weapon".into(),
2247                    floor: 0,
2248                    x0: 18.5,
2249                    y0: 0.0,
2250                    x1: 25.5,
2251                    y1: 12.5,
2252                    floor_color: None,
2253                    floor_glyph: None,
2254                },
2255                InteriorRoomView {
2256                    id: "hall_n".into(),
2257                    label: "Hall N".into(),
2258                    floor: 0,
2259                    x0: -3.5,
2260                    y0: 6.0,
2261                    x1: 12.0,
2262                    y1: 12.5,
2263                    floor_color: None,
2264                    floor_glyph: None,
2265                },
2266                InteriorRoomView {
2267                    id: "meeting".into(),
2268                    label: "Meeting".into(),
2269                    floor: 0,
2270                    x0: -3.5,
2271                    y0: 12.5,
2272                    x1: 12.0,
2273                    y1: 23.5,
2274                    floor_color: None,
2275                    floor_glyph: None,
2276                },
2277                InteriorRoomView {
2278                    id: "office".into(),
2279                    label: "Office".into(),
2280                    floor: 0,
2281                    x0: 12.0,
2282                    y0: 6.0,
2283                    x1: 18.5,
2284                    y1: 12.5,
2285                    floor_color: None,
2286                    floor_glyph: None,
2287                },
2288            ],
2289            room_doors: vec![],
2290        });
2291        state.entities[0].transform.position = WorldCoord::surface(px, py);
2292        state.entities[0].inside_building = Some("town_hall".into());
2293        state.player = state.entities.first().cloned();
2294        state
2295    }
2296
2297    /// Outer perimeter walls (east/south edges at half-meter coords) must paint even
2298    /// when the wall glyph cell sits outside every room's floor fill (background void).
2299    #[test]
2300    fn outer_perimeter_walls_render_at_positive_anchor() {
2301        let state = town_hall_state(20.0, 6.0);
2302        let view = WorldView::build_with_anchor(
2303            &state,
2304            31,
2305            31,
2306            20.0,
2307            6.0,
2308            None,
2309            WorldViewOptions::terrain_only(),
2310        );
2311        let cell = |x: i32, y: i32| {
2312            let (gx, gy) = world_to_grid(
2313                x as f32,
2314                y as f32,
2315                20.0,
2316                6.0,
2317                15,
2318                15,
2319                view.width,
2320                view.height,
2321            )
2322            .expect("in view");
2323            view.cells[gy * view.width + gx].clone()
2324        };
2325        // Weapon shack east wall (x=25.5 → glyph col 26) on a mid-room row and at the
2326        // top-wall corner (row 13) — the corner cell is background void otherwise.
2327        assert!(
2328            is_wall(&cell(26, 9)),
2329            "weapon east wall at (26,9): {}",
2330            cell(26, 9)
2331        );
2332        assert!(
2333            cell(26, 13) == "+" || cell(26, 13) == "-" || cell(26, 13) == "|",
2334            "top wall east corner (26,13): {}",
2335            cell(26, 13)
2336        );
2337        // Small office east wall (x=18.5 → glyph col 19) on a mid-row.
2338        assert!(
2339            is_wall(&cell(19, 9)),
2340            "office east wall at (19,9): {}",
2341            cell(19, 9)
2342        );
2343        // Kitchen east wall (x=23.5 → glyph col 24) on a mid-row.
2344        assert!(
2345            is_wall(&cell(24, -4)),
2346            "kitchen east wall at (24,-4): {}",
2347            cell(24, -4)
2348        );
2349        // Bottom wall spans the merged kitchen+main hall width incl. the kitchen east corner.
2350        assert!(
2351            cell(24, -8) == "+" || cell(24, -8) == "-" || cell(24, -8) == "|",
2352            "bottom wall kitchen-east corner (24,-8): {}",
2353            cell(24, -8)
2354        );
2355        // Top wall spans the full merged width to the weapon east corner (x=26, row 13).
2356        assert!(
2357            cell(12, 13) == "+" || cell(12, 13) == "-" || cell(12, 13) == "|",
2358            "top wall at (12,13): {}",
2359            cell(12, 13)
2360        );
2361    }
2362}