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