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