Skip to main content

flatland_client_ui/
world.rs

1use flatland_client_lib::GameState;
2use flatland_protocol::{BuildingView, InteriorMapView, TerrainKindView};
3use crate::color::RgbColor;
4
5use crate::map_presentation::{self, MapPresentation};
6
7/// The TUI map uses one terminal cell per world meter on both axes so terrain, entities,
8/// resources, and mouse targeting share the same grid. Terminal fonts are taller than wide,
9/// so buildings look vertically stretched compared to the content-admin editor.
10
11pub struct WorldView {
12    pub width: usize,
13    pub height: usize,
14    pub cells: Vec<String>,
15    /// Per-cell foreground color from presentation catalog (None = entity-specific style).
16    pub cell_fg: Vec<Option<RgbColor>>,
17    /// T1 target ring (red).
18    pub target_t1_cells: Vec<bool>,
19    /// T2 target ring (blue).
20    pub target_t2_cells: Vec<bool>,
21    pub origin_x: f32,
22    pub origin_y: f32,
23    pub inside_building: Option<String>,
24}
25
26/// Presentation flags for [`WorldView::build_with_options`].
27#[derive(Debug, Clone, Copy)]
28pub struct WorldViewOptions {
29    /// Paint local player `@` at view center (TUI). Gfx uses a sprite instead.
30    pub paint_local_player: bool,
31    /// Paint NPCs, resources, loot, chests, quest boards, entities into the cell grid.
32    /// Gfx sets this false and draws those at continuous world coordinates.
33    pub paint_overlays: bool,
34}
35
36impl Default for WorldViewOptions {
37    fn default() -> Self {
38        Self {
39            paint_local_player: true,
40            paint_overlays: true,
41        }
42    }
43}
44
45impl WorldViewOptions {
46    /// Terrain / walls only — gfx draws movers at precise x,y.
47    pub fn terrain_only() -> Self {
48        Self {
49            paint_local_player: false,
50            paint_overlays: false,
51        }
52    }
53}
54
55impl WorldView {
56    pub fn build_with_target(
57        state: &GameState,
58        view_w: usize,
59        view_h: usize,
60        map_target: Option<(f32, f32)>,
61    ) -> Self {
62        Self::build_with_options(
63            state,
64            view_w,
65            view_h,
66            map_target,
67            WorldViewOptions::default(),
68        )
69    }
70
71    /// Like [`build_with_target`] with gfx/TUI presentation flags.
72    pub fn build_with_options(
73        state: &GameState,
74        view_w: usize,
75        view_h: usize,
76        map_target: Option<(f32, f32)>,
77        options: WorldViewOptions,
78    ) -> Self {
79        let (px, py) = state.player_position();
80        Self::build_with_anchor(state, view_w, view_h, px, py, map_target, options)
81    }
82
83    /// Build a view anchored at explicit world coordinates (gfx uses smoothed camera).
84    pub fn build_with_anchor(
85        state: &GameState,
86        view_w: usize,
87        view_h: usize,
88        anchor_x: f32,
89        anchor_y: f32,
90        map_target: Option<(f32, f32)>,
91        options: WorldViewOptions,
92    ) -> Self {
93        map_presentation::maybe_reload_for_content_rev(state.content_rev);
94        let px = anchor_x;
95        let py = anchor_y;
96        let inside_building = state.effective_inside_building();
97
98        let width = view_w.max(3);
99        let height = view_h.max(3);
100        let grass = map_presentation::terrain_for(TerrainKindView::Grass);
101        let mut cells = vec![grass.glyph.clone(); width * height];
102        let mut cell_fg = vec![Some(grass.color); width * height];
103
104        let half_w = (width / 2) as i32;
105        let half_h = (height / 2) as i32;
106
107        // Interior rendering is authoritative from `inside_building`, not stale `interior_map`.
108        if let (Some(_bid), Some(interior)) = (
109            inside_building.as_deref(),
110            state.interior_map.as_ref(),
111        ) {
112            paint_interior_background(&mut cells, &mut cell_fg, width, height, interior);
113            paint_interior_rooms(
114                &mut cells,
115                &mut cell_fg,
116                width,
117                height,
118                interior,
119                px,
120                py,
121                half_w,
122                half_h,
123            );
124            let door_gaps = interior_door_gaps(interior, &state.doors);
125            for room in &interior.rooms {
126                paint_building_walls(
127                    &mut cells,
128                    &mut cell_fg,
129                    width,
130                    height,
131                    room.x0,
132                    room.y0,
133                    room.x1 - room.x0,
134                    room.y1 - room.y0,
135                    px,
136                    py,
137                    half_w,
138                    half_h,
139                    &door_gaps,
140                );
141            }
142        } else {
143            paint_terrain(
144                &mut cells,
145                &mut cell_fg,
146                width,
147                height,
148                state,
149                px,
150                py,
151                half_w,
152                half_h,
153            );
154
155            for building in &state.buildings {
156                if building.tags.iter().any(|t| t == "well") {
157                    paint_well(
158                        &mut cells,
159                        &mut cell_fg,
160                        width,
161                        height,
162                        building,
163                        px,
164                        py,
165                        half_w,
166                        half_h,
167                    );
168                } else {
169                    paint_building_walls_centered(
170                        &mut cells,
171                        &mut cell_fg,
172                        width,
173                        height,
174                        building,
175                        px,
176                        py,
177                        half_w,
178                        half_h,
179                    );
180                }
181            }
182        }
183
184        if options.paint_overlays {
185            for door in &state.doors {
186                if let Some((gx, gy)) = world_to_grid(
187                    door.x,
188                    door.y,
189                    px,
190                    py,
191                    half_w,
192                    half_h,
193                    width,
194                    height,
195                ) {
196                    let idx = gy * width + gx;
197                    paint_presentation(
198                        &mut cells,
199                        &mut cell_fg,
200                        idx,
201                        &map_presentation::door_presentation(door.open),
202                    );
203                }
204            }
205
206            for npc in &state.npcs {
207                if let Some((gx, gy)) = world_to_grid(
208                    npc.x,
209                    npc.y,
210                    px,
211                    py,
212                    half_w,
213                    half_h,
214                    width,
215                    height,
216                ) {
217                    let idx = gy * width + gx;
218                    paint_presentation(
219                        &mut cells,
220                        &mut cell_fg,
221                        idx,
222                        &map_presentation::npc_for(npc),
223                    );
224                }
225            }
226
227            let ground = empty_ground_glyph();
228            let player_glyph = map_presentation::player_presentation().glyph;
229            for node in &state.resource_nodes {
230                if let Some((gx, gy)) = world_to_grid(
231                    node.x,
232                    node.y,
233                    px,
234                    py,
235                    half_w,
236                    half_h,
237                    width,
238                    height,
239                ) {
240                    let idx = gy * width + gx;
241                    let pres = map_presentation::resource_for(node);
242                    if can_paint_world_object(&cells[idx], &ground, &player_glyph) {
243                        paint_presentation(&mut cells, &mut cell_fg, idx, &pres);
244                    }
245                }
246            }
247
248            for drop in &state.ground_drops {
249                if let Some((gx, gy)) = world_to_grid(
250                    drop.x,
251                    drop.y,
252                    px,
253                    py,
254                    half_w,
255                    half_h,
256                    width,
257                    height,
258                ) {
259                    let idx = gy * width + gx;
260                    if can_paint_world_object(&cells[idx], &ground, &player_glyph) {
261                        paint_presentation(
262                            &mut cells,
263                            &mut cell_fg,
264                            idx,
265                            &map_presentation::loot_presentation(),
266                        );
267                    }
268                }
269            }
270
271            for chest in &state.placed_containers {
272                if let Some((gx, gy)) = world_to_grid(
273                    chest.x,
274                    chest.y,
275                    px,
276                    py,
277                    half_w,
278                    half_h,
279                    width,
280                    height,
281                ) {
282                    let idx = gy * width + gx;
283                    if can_paint_world_object(&cells[idx], &ground, &player_glyph) {
284                        paint_presentation(
285                            &mut cells,
286                            &mut cell_fg,
287                            idx,
288                            &map_presentation::chest_presentation(chest.locked),
289                        );
290                    }
291                }
292            }
293
294            if state.effective_inside_building().is_none() {
295                for inter in &state.interactables {
296                    if inter.kind != "quest_board" {
297                        continue;
298                    }
299                    if let Some((gx, gy)) = world_to_grid(
300                        inter.x,
301                        inter.y,
302                        px,
303                        py,
304                        half_w,
305                        half_h,
306                        width,
307                        height,
308                    ) {
309                        let idx = gy * width + gx;
310                        if can_paint_world_object(&cells[idx], &ground, &player_glyph) {
311                            paint_presentation(
312                                &mut cells,
313                                &mut cell_fg,
314                                idx,
315                                &map_presentation::quest_board_presentation(),
316                            );
317                        }
318                    }
319                }
320            }
321
322            for entity in &state.entities {
323                // When gfx draws a sprite for the local player, omit the `@` glyph.
324                if !options.paint_local_player && entity.id == state.entity_id {
325                    continue;
326                }
327                if let Some(pres) = entity_presentation(entity, state.entity_id) {
328                    if let Some((gx, gy)) = world_to_grid(
329                        entity.transform.position.x,
330                        entity.transform.position.y,
331                        px,
332                        py,
333                        half_w,
334                        half_h,
335                        width,
336                        height,
337                    ) {
338                        let idx = gy * width + gx;
339                        paint_presentation(&mut cells, &mut cell_fg, idx, &pres);
340                    }
341                }
342            }
343        }
344
345        if options.paint_local_player {
346            if state.player_entity().is_some() {
347                let cx = half_w as usize;
348                let cy = half_h as usize;
349                let idx = cy * width + cx;
350                if idx < cells.len() {
351                    paint_presentation(
352                        &mut cells,
353                        &mut cell_fg,
354                        idx,
355                        &map_presentation::player_presentation(),
356                    );
357                }
358            }
359        }
360
361        let mut target_t1_cells = vec![false; cells.len()];
362        let mut target_t2_cells = vec![false; cells.len()];
363        if options.paint_overlays {
364            for (slot, cells_out) in [(1, &mut target_t1_cells), (2, &mut target_t2_cells)] {
365                let target_id = state
366                    .combat_slots
367                    .iter()
368                    .find(|s| s.slot_index == slot)
369                    .and_then(|s| s.target_entity_id)
370                    .or_else(|| if slot == 1 { state.combat_target } else { None });
371                let Some(target_id) = target_id else {
372                    continue;
373                };
374                if let Some(entity) = state.entities.iter().find(|e| e.id == target_id) {
375                    if let Some((gx, gy)) = world_to_grid(
376                        entity.transform.position.x,
377                        entity.transform.position.y,
378                        px,
379                        py,
380                        half_w,
381                        half_h,
382                        width,
383                        height,
384                    ) {
385                        let idx = gy * width + gx;
386                        if idx < cells_out.len() {
387                            cells_out[idx] = true;
388                        }
389                    }
390                }
391            }
392        }
393
394        if options.paint_overlays {
395            if let Some((tx, ty)) = map_target {
396                if let Some((gx, gy)) = world_to_grid(
397                    tx,
398                    ty,
399                    px,
400                    py,
401                    half_w,
402                    half_h,
403                    width,
404                    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) {
485    let default_color = interior
486        .default_floor_color
487        .as_deref()
488        .and_then(crate::color::parse_color)
489        .unwrap_or(RgbColor::rgb(0x2a, 0x2a, 0x2a));
490    for room in &interior.rooms {
491        let floor_color = room
492            .floor_color
493            .as_deref()
494            .and_then(crate::color::parse_color)
495            .unwrap_or(default_color);
496        let glyph = room.floor_glyph.as_deref().unwrap_or(".").to_string();
497        let x0 = room.x0.floor() as i32;
498        let y0 = room.y0.floor() as i32;
499        let x1 = room.x1.ceil() as i32 - 1;
500        let y1 = room.y1.ceil() as i32 - 1;
501        for wy in y0..=y1 {
502            for wx in x0..=x1 {
503                if let Some((gx, gy)) = world_to_grid(
504                    wx as f32,
505                    wy as f32,
506                    px,
507                    py,
508                    half_w,
509                    half_h,
510                    width,
511                    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) -> Vec<(f32, f32)> {
526    let mut gaps: Vec<(f32, f32)> = interior
527        .room_doors
528        .iter()
529        .filter_map(|d| {
530            doors
531                .iter()
532                .find(|door| door.id == d.id)
533                .filter(|door| door.open)
534                .map(|door| (door.x, door.y))
535        })
536        .collect();
537    for door in doors {
538        if door.portal.is_some() && door.open {
539            gaps.push((door.x, door.y));
540        }
541    }
542    gaps
543}
544
545fn near_door_gap(wx: i32, wy: i32, door_gaps: &[(f32, f32)]) -> bool {
546    door_gaps.iter().any(|(dx, dy)| {
547        (wx as f32 - dx).abs() < 0.75 && (wy as f32 - dy).abs() < 0.75
548    })
549}
550
551fn paint_terrain(
552    cells: &mut [String],
553    cell_fg: &mut [Option<RgbColor>],
554    width: usize,
555    height: usize,
556    state: &GameState,
557    px: f32,
558    py: f32,
559    _half_w: i32,
560    _half_h: i32,
561) {
562    for gy in 0..height {
563        for gx in 0..width {
564            let Some((wx, wy)) = grid_to_world(gx, gy, px, py, width, height) else {
565                continue;
566            };
567            let zone = state.terrain_zone_at(wx, wy);
568            let kind = zone
569                .map(|z| z.kind)
570                .unwrap_or(TerrainKindView::Grass);
571            let elev = zone.map(|z| z.elevation).unwrap_or(0.0);
572            let style = map_presentation::terrain_for_zone(
573                kind,
574                elev,
575                zone.and_then(|z| z.glyph.as_deref()),
576                zone.and_then(|z| z.color.as_deref()),
577            );
578            let idx = gy * width + gx;
579            paint_presentation(cells, cell_fg, idx, &style);
580        }
581    }
582}
583
584fn paint_well(
585    cells: &mut [String],
586    cell_fg: &mut [Option<RgbColor>],
587    width: usize,
588    height: usize,
589    building: &BuildingView,
590    px: f32,
591    py: f32,
592    half_w: i32,
593    half_h: i32,
594) {
595    let hw = building.width_m / 2.0;
596    let hd = building.depth_m / 2.0;
597    let x0 = (building.x - hw).floor() as i32;
598    let y0 = (building.y - hd).floor() as i32;
599    let x1 = (building.x + hw).ceil() as i32 - 1;
600    let y1 = (building.y + hd).ceil() as i32 - 1;
601    let water = map_presentation::shallow_water_presentation();
602    let center = map_presentation::well_center_presentation();
603
604    for wy in y0..=y1 {
605        for wx in x0..=x1 {
606            let pres = if wx == building.x.round() as i32 && wy == building.y.round() as i32 {
607                center.clone()
608            } else {
609                water.clone()
610            };
611            if let Some((gx, gy)) = world_to_grid(
612                wx as f32,
613                wy as f32,
614                px,
615                py,
616                half_w,
617                half_h,
618                width,
619                height,
620            ) {
621                let idx = gy * width + gx;
622                if !is_wall(&cells[idx]) {
623                    paint_presentation(cells, cell_fg, idx, &pres);
624                }
625            }
626        }
627    }
628}
629
630fn paint_building_walls_centered(
631    cells: &mut [String],
632    cell_fg: &mut [Option<RgbColor>],
633    width: usize,
634    height: usize,
635    building: &BuildingView,
636    px: f32,
637    py: f32,
638    half_w: i32,
639    half_h: i32,
640) {
641    let hw = building.width_m / 2.0;
642    let hd = building.depth_m / 2.0;
643    paint_building_walls(
644        cells,
645        cell_fg,
646        width,
647        height,
648        building.x - hw,
649        building.y - hd,
650        building.width_m,
651        building.depth_m,
652        px,
653        py,
654        half_w,
655        half_h,
656        &[],
657    );
658}
659
660/// Paint perimeter walls for an axis-aligned rectangle (origin = south-west corner).
661fn paint_building_walls(
662    cells: &mut [String],
663    cell_fg: &mut [Option<RgbColor>],
664    width: usize,
665    height: usize,
666    origin_x: f32,
667    origin_y: f32,
668    width_m: f32,
669    depth_m: f32,
670    px: f32,
671    py: f32,
672    half_w: i32,
673    half_h: i32,
674    door_gaps: &[(f32, f32)],
675) {
676    let x0 = origin_x.floor() as i32;
677    let y0 = origin_y.floor() as i32;
678    let x1 = (origin_x + width_m).ceil() as i32 - 1;
679    let y1 = (origin_y + depth_m).ceil() as i32 - 1;
680
681    if x1 < x0 || y1 < y0 {
682        return;
683    }
684
685    for wx in x0..=x1 {
686        if !near_door_gap(wx, y0, door_gaps) {
687            paint_wall_cell(cells, cell_fg, width, height, wx, y0, px, py, half_w, half_h, "-");
688        }
689        if !near_door_gap(wx, y1, door_gaps) {
690            paint_wall_cell(cells, cell_fg, width, height, wx, y1, px, py, half_w, half_h, "-");
691        }
692    }
693    for wy in y0 + 1..y1 {
694        if !near_door_gap(x0, wy, door_gaps) {
695            paint_wall_cell(cells, cell_fg, width, height, x0, wy, px, py, half_w, half_h, "|");
696        }
697        if !near_door_gap(x1, wy, door_gaps) {
698            paint_wall_cell(cells, cell_fg, width, height, x1, wy, px, py, half_w, half_h, "|");
699        }
700    }
701    if !near_door_gap(x0, y0, door_gaps) {
702        paint_wall_cell(cells, cell_fg, width, height, x0, y0, px, py, half_w, half_h, "+");
703    }
704    if !near_door_gap(x1, y0, door_gaps) {
705        paint_wall_cell(cells, cell_fg, width, height, x1, y0, px, py, half_w, half_h, "+");
706    }
707    if !near_door_gap(x0, y1, door_gaps) {
708        paint_wall_cell(cells, cell_fg, width, height, x0, y1, px, py, half_w, half_h, "+");
709    }
710    if !near_door_gap(x1, y1, door_gaps) {
711        paint_wall_cell(cells, cell_fg, width, height, x1, y1, px, py, half_w, half_h, "+");
712    }
713}
714
715fn paint_wall_cell(
716    cells: &mut [String],
717    cell_fg: &mut [Option<RgbColor>],
718    width: usize,
719    height: usize,
720    wx: i32,
721    wy: i32,
722    px: f32,
723    py: f32,
724    half_w: i32,
725    half_h: i32,
726    ch: &str,
727) {
728    let Some((gx, gy)) = world_to_grid(
729        wx as f32,
730        wy as f32,
731        px,
732        py,
733        half_w,
734        half_h,
735        width,
736        height,
737    ) else {
738        return;
739    };
740    let idx = gy * width + gx;
741    let ground = empty_ground_glyph();
742    if cells[idx] == ground || is_wall(&cells[idx]) {
743        cells[idx] = merge_wall_corner(&cells[idx], ch, &ground);
744        cell_fg[idx] = Some(map_presentation::wall_presentation().color);
745    }
746}
747
748fn is_wall(glyph: &str) -> bool {
749    matches!(
750        glyph.chars().next(),
751        Some('+' | '-' | '|')
752    )
753}
754
755/// Overlays (resources, loot, chests, boards) paint on any non-wall cell.
756fn can_paint_world_object(glyph: &str, _ground: &str, _player_glyph: &str) -> bool {
757    !is_wall(glyph)
758}
759
760fn merge_wall_corner(existing: &str, incoming: &str, ground: &str) -> String {
761    if existing == ground {
762        return incoming.to_string();
763    }
764    if existing == incoming {
765        return existing.to_string();
766    }
767    "+".to_string()
768}
769
770fn world_to_grid(
771    x: f32,
772    y: f32,
773    px: f32,
774    py: f32,
775    half_w: i32,
776    half_h: i32,
777    width: usize,
778    height: usize,
779) -> Option<(usize, usize)> {
780    let dx = (x - px).round() as i32;
781    let dy = (y - py).round() as i32;
782
783    if dx.abs() > half_w || dy.abs() > half_h {
784        return None;
785    }
786
787    let gx = half_w + dx;
788    let gy = half_h - dy;
789
790    if gx < 0 || gy < 0 {
791        return None;
792    }
793    let gx = gx as usize;
794    let gy = gy as usize;
795    if gx >= width || gy >= height {
796        return None;
797    }
798    Some((gx, gy))
799}
800
801/// Inverse of [`world_to_grid`]: map view cell → world meters (cell center).
802pub fn grid_to_world(
803    gx: usize,
804    gy: usize,
805    px: f32,
806    py: f32,
807    view_w: usize,
808    view_h: usize,
809) -> Option<(f32, f32)> {
810    if gx >= view_w || gy >= view_h {
811        return None;
812    }
813    let half_w = (view_w / 2) as i32;
814    let half_h = (view_h / 2) as i32;
815    let dx = gx as i32 - half_w;
816    let dy = half_h - gy as i32;
817    Some((px + dx as f32, py + dy as f32))
818}
819
820#[cfg(test)]
821mod tests {
822    use super::*;
823    use flatland_protocol::{BuildingView, EntityState, PlayerVitals, PrimaryAttributes, Transform, WorldCoord};
824
825    fn state_with_building(building: BuildingView) -> GameState {
826        GameState {
827            session_id: 1,
828            entity_id: 1,
829            character_id: None,
830            tick: 0,
831            chunk_rev: 0,
832            content_rev: 0,
833            entities: vec![EntityState {
834                id: 1,
835                label: "You".into(),
836                transform: Transform {
837                    position: WorldCoord::surface(148.0, 118.0),
838                    yaw: 0.0,
839                    velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
840                },
841                vitals: Some(PlayerVitals::default()),
842                attributes: Some(PrimaryAttributes::default()),
843                skills: Some(flatland_protocol::PlayerSkills::default()),
844                inside_building: None,
845                tile_id: None,
846                presentation_state: None,
847                sprite_mode: None,
848            }],
849            player: None,
850            resource_nodes: vec![],
851            ground_drops: vec![],
852            placed_containers: vec![],
853            buildings: vec![building],
854            doors: vec![],
855            interior_map: None,
856            npcs: vec![],
857            blueprints: vec![],
858            world_width_m: 256.0,
859            world_height_m: 256.0,
860            terrain_zones: vec![],
861            z_platforms: vec![],
862            z_transitions: vec![],
863            world_clock: flatland_protocol::WorldClock::default(),
864            inventory: Default::default(),
865            inventory_hints: Default::default(),
866            logs: Default::default(),
867            intents_sent: 0,
868            ticks_received: 0,
869            connected: true,
870            disconnect_reason: None,
871            show_stats: false,
872            show_craft_menu: false,
873            craft_menu_index: 0,
874            craft_batch_quantity: 1,
875            show_shop_menu: false,
876            shop_catalog: None,
877            shop_tab: flatland_client_lib::ShopTab::default(),
878            shop_menu_index: 0,
879            shop_quantity: 1,
880            shop_trade_log: std::collections::VecDeque::new(),
881            show_npc_verb_menu: false,
882            npc_verb_target: None,
883            npc_verb_index: 0,
884            show_npc_chat: false,
885            npc_chat: None,
886            show_inventory_menu: false,
887            inventory_menu_index: 0,
888            show_move_picker: false,
889            show_rename_prompt: false,
890            rename_buffer: String::new(),
891            move_picker_index: 0,
892            move_picker: None,
893            show_destroy_picker: false,
894            destroy_confirm_pending: false,
895            destroy_picker: None,
896            combat_target: None,
897            combat_target_label: None,
898            in_combat: false,
899            auto_attack: true,
900            combat_has_los: false,
901            attack_cd_ticks: 0,
902            gcd_ticks: 0,
903            weapon_ability_id: "unarmed".into(),
904            mainhand_template_id: None,
905            mainhand_label: None,
906            worn: std::collections::BTreeMap::new(),
907            carry_mass: 0.0,
908            carry_mass_max: 0.0,
909            encumbrance: flatland_protocol::EncumbranceState::Light,
910            inventory_stacks: Vec::new(),
911            keychain_stacks: Vec::new(),
912            combat_target_detail: None,
913            cast_progress: None,
914            ability_cooldowns: Vec::new(),
915            blocking_active: false,
916            max_target_slots: 1,
917            combat_slots: Vec::new(),
918            rotation_presets: Vec::new(),
919            show_loadout_menu: false,
920            show_keychain_menu: false,
921            keychain_menu_index: 0,
922            show_rotation_editor: false,
923            loadout_menu_index: 0,
924            rotation_editor: Default::default(),
925            harvest_in_progress: false,
926            harvest_started_at: None,
927            pending_craft_ack: None,
928            quest_log: Vec::new(),
929            interactables: Vec::new(),
930            show_quest_offer: false,
931            pending_quest_offer: None,
932            show_quest_menu: false,
933            quest_menu_index: 0,
934            quest_withdraw_confirm: false,
935        }
936    }
937
938    #[test]
939    fn shallow_water_terrain_paints_tilde() {
940        use flatland_protocol::TerrainZoneView;
941        let mut state = state_with_building(BuildingView {
942            id: "x".into(),
943            label: "X".into(),
944            x: 128.0,
945            y: 128.0,
946            width_m: 1.0,
947            depth_m: 1.0,
948            interior_blueprint: None,
949            tags: vec![],
950        });
951        state.terrain_zones.push(TerrainZoneView {
952            id: "pond".into(),
953            x0: 126.0,
954            y0: 126.0,
955            x1: 130.0,
956            y1: 130.0,
957            kind: TerrainKindView::ShallowWater,
958            elevation: -0.5,
959            glyph: None,
960            color: None,
961            tile_id: None,
962        });
963        state.entities = vec![EntityState {
964            id: 1,
965            label: "You".into(),
966            transform: Transform {
967                position: WorldCoord::surface(128.0, 128.0),
968                yaw: 0.0,
969                velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
970            },
971            vitals: Some(PlayerVitals::default()),
972            attributes: Some(PrimaryAttributes::default()),
973            skills: Some(flatland_protocol::PlayerSkills::default()),
974            inside_building: None,
975            tile_id: None,
976            presentation_state: None,
977            sprite_mode: None,
978        }];
979        state.player = state.entities.first().cloned();
980        let view = WorldView::build_with_target(&state, 9, 9, None);
981        let flat = view.cells.join("");
982        let water = map_presentation::shallow_water_presentation();
983        assert!(
984            flat.contains(&water.glyph),
985            "expected water tiles ({:?}): {flat}",
986            water.glyph
987        );
988        assert!(
989            view.cell_fg.iter().any(|c| *c == Some(water.color)),
990            "expected water color on terrain cells"
991        );
992    }
993
994    #[test]
995    fn zone_glyph_and_color_overrides_paint_on_map() {
996        use flatland_protocol::TerrainZoneView;
997        let mut state = state_with_building(BuildingView {
998            id: "x".into(),
999            label: "X".into(),
1000            x: 128.0,
1001            y: 128.0,
1002            width_m: 1.0,
1003            depth_m: 1.0,
1004            interior_blueprint: None,
1005            tags: vec![],
1006        });
1007        state.terrain_zones.push(TerrainZoneView {
1008            id: "marked".into(),
1009            x0: 126.0,
1010            y0: 126.0,
1011            x1: 130.0,
1012            y1: 130.0,
1013            kind: TerrainKindView::Grass,
1014            elevation: 0.0,
1015            glyph: Some("%".into()),
1016            color: Some("magenta".into()),
1017            tile_id: None,
1018        });
1019        state.entities = vec![EntityState {
1020            id: 1,
1021            label: "You".into(),
1022            transform: Transform {
1023                position: WorldCoord::surface(128.0, 128.0),
1024                yaw: 0.0,
1025                velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
1026            },
1027            vitals: Some(PlayerVitals::default()),
1028            attributes: Some(PrimaryAttributes::default()),
1029            skills: Some(flatland_protocol::PlayerSkills::default()),
1030            inside_building: None,
1031            tile_id: None,
1032            presentation_state: None,
1033            sprite_mode: None,
1034        }];
1035        state.player = state.entities.first().cloned();
1036        let view = WorldView::build_with_target(&state, 9, 9, None);
1037        let flat = view.cells.join("");
1038        assert!(flat.contains('%'), "expected custom zone glyph on map: {flat}");
1039        assert!(
1040            view.cell_fg.iter().any(|c| *c == Some(RgbColor::MAGENTA)),
1041            "expected custom zone color on terrain cells"
1042        );
1043    }
1044
1045    #[test]
1046    fn well_paints_water_ring_and_center() {
1047        let building = BuildingView {
1048            id: "town_well".into(),
1049            label: "Well".into(),
1050            x: 122.0,
1051            y: 106.0,
1052            width_m: 3.0,
1053            depth_m: 3.0,
1054            interior_blueprint: None,
1055            tags: vec!["well".into()],
1056        };
1057        let mut state = state_with_building(building);
1058        state.entities = vec![EntityState {
1059            id: 1,
1060            label: "You".into(),
1061            transform: Transform {
1062                position: WorldCoord::surface(120.0, 106.0),
1063                yaw: 0.0,
1064                velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
1065            },
1066            vitals: Some(PlayerVitals::default()),
1067            attributes: Some(PrimaryAttributes::default()),
1068            skills: Some(flatland_protocol::PlayerSkills::default()),
1069            inside_building: None,
1070            tile_id: None,
1071            presentation_state: None,
1072            sprite_mode: None,
1073        }];
1074        state.player = state.entities.first().cloned();
1075        let view = WorldView::build_with_target(&state, 9, 9, None);
1076        let flat = view.cells.join("");
1077        assert!(flat.contains('~'), "expected well water: {flat}");
1078        assert!(flat.contains('O'), "expected well center: {flat}");
1079    }
1080
1081    #[test]
1082    fn broker_hut_draws_wall_outline() {
1083        let building = BuildingView {
1084            id: "broker_hut".into(),
1085            label: "Broker's Hut".into(),
1086            x: 148.0,
1087            y: 118.0,
1088            width_m: 8.0,
1089            depth_m: 6.0,
1090            interior_blueprint: None,
1091            tags: vec![],
1092        };
1093        let mut state = state_with_building(building);
1094        state.player = state.entities.first().cloned();
1095        let view = WorldView::build_with_target(&state, 25, 15, None);
1096        let flat = view.cells.join("");
1097        assert!(flat.contains('+'), "expected corners: {flat}");
1098        assert!(flat.contains('-'), "expected horiz walls: {flat}");
1099        assert!(flat.contains('|'), "expected vert walls: {flat}");
1100    }
1101
1102    #[test]
1103    fn interior_map_renders_rooms_and_walls() {
1104        use flatland_protocol::{InteriorMapView, InteriorRoomView};
1105        let building = BuildingView {
1106            id: "broker_hut".into(),
1107            label: "Broker's Hut".into(),
1108            x: 148.0,
1109            y: 118.0,
1110            width_m: 8.0,
1111            depth_m: 6.0,
1112            interior_blueprint: Some("broker_hut".into()),
1113            tags: vec![],
1114        };
1115        let mut state = state_with_building(building);
1116        state.interior_map = Some(InteriorMapView {
1117            building_id: "broker_hut".into(),
1118            blueprint_id: "broker_hut".into(),
1119            background_color: "#000000".into(),
1120            default_floor_color: Some("#2a2a2a".into()),
1121            floor_height_m: 3.0,
1122            rooms: vec![InteriorRoomView {
1123                id: "main".into(),
1124                label: "Main".into(),
1125                floor: 0,
1126                x0: 0.0,
1127                y0: 0.0,
1128                x1: 8.5,
1129                y1: 7.0,
1130                floor_color: None,
1131                floor_glyph: None,
1132            }],
1133            room_doors: vec![],
1134        });
1135        state.entities = vec![EntityState {
1136            id: 1,
1137            label: "You".into(),
1138            transform: Transform {
1139                position: WorldCoord::surface(4.0, 3.0),
1140                yaw: 0.0,
1141                velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
1142            },
1143            vitals: Some(PlayerVitals::default()),
1144            attributes: Some(PrimaryAttributes::default()),
1145            skills: Some(flatland_protocol::PlayerSkills::default()),
1146            inside_building: Some("broker_hut".into()),
1147            tile_id: None,
1148            presentation_state: None,
1149            sprite_mode: None,
1150        }];
1151        state.player = state.entities.first().cloned();
1152        let view = WorldView::build_with_target(&state, 25, 15, None);
1153        assert_eq!(view.inside_building.as_deref(), Some("broker_hut"));
1154        let flat = view.cells.join("");
1155        assert!(flat.contains('+'), "expected interior walls: {flat}");
1156        assert!(flat.contains('@'), "expected player marker: {flat}");
1157    }
1158
1159    #[test]
1160    fn stale_interior_map_renders_outdoor_when_outside() {
1161        use flatland_protocol::{InteriorMapView, InteriorRoomView};
1162        let building = BuildingView {
1163            id: "town_hall".into(),
1164            label: "Town Hall".into(),
1165            x: 163.0,
1166            y: 137.0,
1167            width_m: 20.0,
1168            depth_m: 10.0,
1169            interior_blueprint: Some("town_hall".into()),
1170            tags: vec![],
1171        };
1172        let mut state = state_with_building(building);
1173        state.interior_map = Some(InteriorMapView {
1174            building_id: "town_hall".into(),
1175            blueprint_id: "town_hall".into(),
1176            background_color: "#000000".into(),
1177            default_floor_color: Some("#2a2a2a".into()),
1178            floor_height_m: 3.0,
1179            rooms: vec![InteriorRoomView {
1180                id: "main_hall".into(),
1181                label: "Main".into(),
1182                floor: 0,
1183                x0: -3.5,
1184                y0: -8.0,
1185                x1: 18.5,
1186                y1: 6.0,
1187                floor_color: None,
1188                floor_glyph: None,
1189            }],
1190            room_doors: vec![],
1191        });
1192        state.player = state.entities.first().cloned();
1193        let view = WorldView::build_with_target(&state, 25, 15, None);
1194        assert!(view.inside_building.is_none());
1195        let flat = view.cells.join("");
1196        let grass = map_presentation::terrain_for(TerrainKindView::Grass).glyph;
1197        assert!(
1198            flat.contains(&grass),
1199            "expected outdoor terrain, not stale interior background: {flat}"
1200        );
1201        assert!(
1202            !flat.chars().all(|c| c == ' ' || c == '@'),
1203            "stale interior_map must not paint black interior when outside"
1204        );
1205    }
1206
1207    #[test]
1208    fn stale_inside_flag_still_renders_outdoor_world() {
1209        use flatland_protocol::ResourceNodeState;
1210        let building = BuildingView {
1211            id: "broker_hut".into(),
1212            label: "Broker's Hut".into(),
1213            x: 148.0,
1214            y: 118.0,
1215            width_m: 8.0,
1216            depth_m: 6.0,
1217            interior_blueprint: None,
1218            tags: vec![],
1219        };
1220        let mut state = state_with_building(building);
1221        state.entities = vec![EntityState {
1222            id: 1,
1223            label: "You".into(),
1224            transform: Transform {
1225                position: WorldCoord::surface(128.0, 128.0),
1226                yaw: 0.0,
1227                velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
1228            },
1229            vitals: Some(PlayerVitals::default()),
1230            attributes: Some(PrimaryAttributes::default()),
1231            skills: Some(flatland_protocol::PlayerSkills::default()),
1232            inside_building: Some("broker_hut".into()),
1233            tile_id: None,
1234            presentation_state: None,
1235            sprite_mode: None,
1236        }];
1237        state.player = state.entities.first().cloned();
1238        state.resource_nodes.push(flatland_protocol::ResourceNodeView {
1239            id: "oak".into(),
1240            label: "Oak".into(),
1241            x: 126.0,
1242            y: 134.0,
1243            z: 0.0,
1244            item_template: "oak_log".into(),
1245            state: ResourceNodeState::Available,
1246            blocking: true,
1247            blocking_radius_m: 0.8,
1248            tile_id: None,
1249            sprite_mode: None,
1250            presentation_state: None,
1251        });
1252        let view = WorldView::build_with_target(&state, 25, 15, None);
1253        assert_eq!(
1254            view.inside_building.as_deref(),
1255            Some("broker_hut"),
1256            "server inside flag is authoritative"
1257        );
1258        let flat = view.cells.join("");
1259        let oak = map_presentation::resource_for(&flatland_protocol::ResourceNodeView {
1260            id: "oak".into(),
1261            label: "Oak".into(),
1262            x: 0.0,
1263            y: 0.0,
1264            z: 0.0,
1265            item_template: "oak_log".into(),
1266            state: flatland_protocol::ResourceNodeState::Available,
1267            blocking: true,
1268            blocking_radius_m: 0.8,
1269            tile_id: None,
1270            sprite_mode: None,
1271            presentation_state: None,
1272        });
1273        assert!(
1274            flat.contains(&oak.glyph),
1275            "expected nearby tree ({:?}): {flat}",
1276            oak.glyph
1277        );
1278        assert!(flat.contains('@'), "expected player: {flat}");
1279    }
1280
1281    #[test]
1282    fn inside_building_flag_selects_active_instance() {
1283        let town = BuildingView {
1284            id: "town_hall".into(),
1285            label: "Town Hall".into(),
1286            x: 160.0,
1287            y: 136.0,
1288            width_m: 20.0,
1289            depth_m: 10.0,
1290            interior_blueprint: Some("town_hall".into()),
1291            tags: vec![],
1292        };
1293        let guild = BuildingView {
1294            id: "guild_hall".into(),
1295            label: "Guild Hall".into(),
1296            x: 164.0,
1297            y: 152.0,
1298            width_m: 20.0,
1299            depth_m: 14.0,
1300            interior_blueprint: Some("guild_hall".into()),
1301            tags: vec![],
1302        };
1303        let mut state = state_with_building(town);
1304        state.buildings.push(guild);
1305        state.entities = vec![EntityState {
1306            id: 1,
1307            label: "You".into(),
1308            transform: Transform {
1309                position: WorldCoord::surface(4.0, 3.0),
1310                yaw: 0.0,
1311                velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
1312            },
1313            vitals: Some(PlayerVitals::default()),
1314            attributes: Some(PrimaryAttributes::default()),
1315            skills: Some(flatland_protocol::PlayerSkills::default()),
1316            inside_building: Some("guild_hall".into()),
1317            tile_id: None,
1318            presentation_state: None,
1319            sprite_mode: None,
1320        }];
1321        state.player = state.entities.first().cloned();
1322        state.world_width_m = 256.0;
1323        state.world_height_m = 256.0;
1324
1325        let view = WorldView::build_with_target(&state, 25, 15, None);
1326        assert_eq!(view.inside_building.as_deref(), Some("guild_hall"));
1327    }
1328
1329    #[test]
1330    fn combat_target_marks_creature_cell() {
1331        let building = BuildingView {
1332            id: "x".into(),
1333            label: "X".into(),
1334            x: 128.0,
1335            y: 128.0,
1336            width_m: 1.0,
1337            depth_m: 1.0,
1338            interior_blueprint: None,
1339            tags: vec![],
1340        };
1341        let mut state = state_with_building(building);
1342        state.entities = vec![
1343            EntityState {
1344                id: 1,
1345                label: "You".into(),
1346                transform: Transform {
1347                    position: WorldCoord::surface(100.0, 100.0),
1348                    yaw: 0.0,
1349                    velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
1350                },
1351                vitals: Some(PlayerVitals::default()),
1352                attributes: Some(PrimaryAttributes::default()),
1353                skills: Some(flatland_protocol::PlayerSkills::default()),
1354                inside_building: None,
1355                tile_id: None,
1356                presentation_state: None,
1357                sprite_mode: None,
1358            },
1359            EntityState {
1360                id: 42,
1361                label: "Rabbit".into(),
1362                transform: Transform {
1363                    position: WorldCoord::surface(103.0, 100.0),
1364                    yaw: 0.0,
1365                    velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
1366                },
1367                vitals: None,
1368                attributes: None,
1369                skills: None,
1370                inside_building: None,
1371                tile_id: None,
1372                presentation_state: None,
1373                sprite_mode: None,
1374            },
1375        ];
1376        state.player = state.entities.first().cloned();
1377        state.combat_target = Some(42);
1378        state.combat_target_label = Some("Rabbit".into());
1379
1380        let view = WorldView::build_with_target(&state, 25, 15, None);
1381        let marked: usize = view
1382            .target_t1_cells
1383            .iter()
1384            .chain(view.target_t2_cells.iter())
1385            .filter(|b| **b)
1386            .count();
1387        assert_eq!(marked, 1, "exactly one targeted cell");
1388        let idx = view
1389            .target_t1_cells
1390            .iter()
1391            .chain(view.target_t2_cells.iter())
1392            .position(|b| *b)
1393            .expect("target cell");
1394        assert_eq!(view.cells[idx], "R");
1395    }
1396
1397    #[test]
1398    fn grid_to_world_roundtrips_center() {
1399        let px = 10.0;
1400        let py = 20.0;
1401        let view_w = 11;
1402        let view_h = 11;
1403        let half_w = (view_w / 2) as i32;
1404        let half_h = (view_h / 2) as i32;
1405        let (gx, gy) = world_to_grid(12.0, 18.0, px, py, half_w, half_h, view_w, view_h)
1406            .expect("in view");
1407        let (wx, wy) = grid_to_world(gx, gy, px, py, view_w, view_h).expect("inverse");
1408        assert!((wx - 12.0).abs() < 0.01);
1409        assert!((wy - 18.0).abs() < 0.01);
1410    }
1411
1412    #[test]
1413    fn vertical_axis_quantizes_to_one_meter() {
1414        let px = 0.0;
1415        let py = 0.0;
1416        let view_w = 21;
1417        let view_h = 21;
1418        let half_w = (view_w / 2) as i32;
1419        let half_h = (view_h / 2) as i32;
1420        let (gx, gy) =
1421            world_to_grid(3.0, 3.0, px, py, half_w, half_h, view_w, view_h).expect("in view");
1422        let (wx, wy) = grid_to_world(gx, gy, px, py, view_w, view_h).expect("inverse");
1423        assert!((wx - 3.0).abs() < 0.01, "x should stay exact to 1m: got {wx}");
1424        assert!((wy - 3.0).abs() < 0.01, "y should stay exact to 1m: got {wy}");
1425    }
1426
1427    #[test]
1428    fn square_extent_spans_equal_rows_and_columns() {
1429        let px = 0.0;
1430        let py = 0.0;
1431        let half_w = 50;
1432        let half_h = 50;
1433        let width = 101;
1434        let height = 101;
1435        let (gx0, gy0) =
1436            world_to_grid(-4.0, -4.0, px, py, half_w, half_h, width, height).expect("in view");
1437        let (gx1, gy1) =
1438            world_to_grid(4.0, 4.0, px, py, half_w, half_h, width, height).expect("in view");
1439        let cols_spanned = (gx1 as i32 - gx0 as i32).unsigned_abs();
1440        let rows_spanned = (gy1 as i32 - gy0 as i32).unsigned_abs();
1441        assert_eq!(cols_spanned, 8, "8m wide should span 8 columns");
1442        assert_eq!(rows_spanned, 8, "8m tall should span 8 rows");
1443    }
1444
1445    #[test]
1446    fn resource_paints_on_terrain_cell_for_same_world_coords() {
1447        use flatland_protocol::{ResourceNodeState, ResourceNodeView, TerrainZoneView};
1448
1449        let px = 128.0;
1450        let py = 128.0;
1451        let rx = 131.0;
1452        let ry = 132.0;
1453        let mut state = state_with_building(BuildingView {
1454            id: "x".into(),
1455            label: "X".into(),
1456            x: 128.0,
1457            y: 128.0,
1458            width_m: 1.0,
1459            depth_m: 1.0,
1460            interior_blueprint: None,
1461            tags: vec![],
1462                    });
1463        state.terrain_zones.push(TerrainZoneView {
1464            id: "pond".into(),
1465            x0: rx,
1466            y0: ry,
1467            x1: rx + 1.0,
1468            y1: ry + 1.0,
1469            kind: TerrainKindView::ShallowWater,
1470            elevation: -0.5,
1471            glyph: None,
1472            color: None,
1473            tile_id: None,
1474        });
1475        state.resource_nodes.push(ResourceNodeView {
1476            id: "oak".into(),
1477            label: "Oak".into(),
1478            x: rx,
1479            y: ry,
1480            z: 0.0,
1481            item_template: "oak_log".into(),
1482            state: ResourceNodeState::Available,
1483            blocking: true,
1484            blocking_radius_m: 0.8,
1485            tile_id: None,
1486            sprite_mode: None,
1487            presentation_state: None,
1488        });
1489        state.entities[0].transform.position = WorldCoord::surface(px, py);
1490        state.player = state.entities.first().cloned();
1491
1492        let view = WorldView::build_with_target(&state, 25, 15, None);
1493        let half_w = (view.width / 2) as i32;
1494        let half_h = (view.height / 2) as i32;
1495        let (gx, gy) = world_to_grid(rx, ry, px, py, half_w, half_h, view.width, view.height)
1496            .expect("resource in view");
1497        let idx = gy * view.width + gx;
1498        let oak = map_presentation::resource_for(&state.resource_nodes[0]);
1499        assert_eq!(
1500            view.cells[idx], oak.glyph,
1501            "resource should paint on the grid cell for its world coords"
1502        );
1503        let (wx, wy) = grid_to_world(gx, gy, px, py, view.width, view.height).expect("inverse");
1504        assert!(
1505            (wx - rx).abs() < 0.01 && (wy - ry).abs() < 0.01,
1506            "resource grid cell should sample terrain at ({wx}, {wy}), expected ({rx}, {ry})"
1507        );
1508    }
1509
1510    #[test]
1511    fn chest_and_loot_paint_on_non_grass_terrain() {
1512        use flatland_protocol::{GroundDropView, PlacedContainerView, TerrainZoneView};
1513
1514        let px = 50.0;
1515        let py = 50.0;
1516        let cx = 53.0;
1517        let cy = 52.0;
1518        let lx = 54.0;
1519        let ly = 52.0;
1520        let mut state = state_with_building(BuildingView {
1521            id: "x".into(),
1522            label: "X".into(),
1523            x: 50.0,
1524            y: 50.0,
1525            width_m: 1.0,
1526            depth_m: 1.0,
1527            interior_blueprint: None,
1528            tags: vec![],
1529        });
1530        state.terrain_zones.push(TerrainZoneView {
1531            id: "trail".into(),
1532            x0: 52.0,
1533            y0: 51.0,
1534            x1: 56.0,
1535            y1: 54.0,
1536            kind: TerrainKindView::Trail,
1537            elevation: 0.0,
1538            glyph: None,
1539            color: None,
1540            tile_id: None,
1541        });
1542        state.placed_containers.push(PlacedContainerView {
1543            id: "chest_1".into(),
1544            template_id: "wood_chest".into(),
1545            display_name: "Storage".into(),
1546            x: cx,
1547            y: cy,
1548            z: 0.0,
1549            locked: false,
1550            accessible: true,
1551            owner_character_id: None,
1552            contents: vec![],
1553            lock_id: None,
1554            capacity_volume: Some(40.0),
1555            item_instance_id: None,
1556            tile_id: None,
1557        });
1558        state.ground_drops.push(GroundDropView {
1559            id: "drop_1".into(),
1560            template_id: "lumber".into(),
1561            quantity: 2,
1562            x: lx,
1563            y: ly,
1564            z: 0.0,
1565            tile_id: None,
1566        });
1567        state.entities[0].transform.position = WorldCoord::surface(px, py);
1568        state.player = state.entities.first().cloned();
1569
1570        let view = WorldView::build_with_target(&state, 25, 15, None);
1571        let half_w = (view.width / 2) as i32;
1572        let half_h = (view.height / 2) as i32;
1573        let (gx, gy) =
1574            world_to_grid(cx, cy, px, py, half_w, half_h, view.width, view.height).expect("chest");
1575        let chest = map_presentation::chest_presentation(false);
1576        assert_eq!(
1577            view.cells[gy * view.width + gx],
1578            chest.glyph,
1579            "chest must paint on trail/non-grass cells"
1580        );
1581        let (gx2, gy2) =
1582            world_to_grid(lx, ly, px, py, half_w, half_h, view.width, view.height).expect("loot");
1583        let loot = map_presentation::loot_presentation();
1584        assert_eq!(
1585            view.cells[gy2 * view.width + gx2],
1586            loot.glyph,
1587            "ground loot must paint on trail/non-grass cells"
1588        );
1589    }
1590
1591    #[test]
1592    fn build_with_anchor_sets_view_origin() {
1593        let mut state = state_with_building(BuildingView {
1594            id: "x".into(),
1595            label: "X".into(),
1596            x: 128.0,
1597            y: 128.0,
1598            width_m: 1.0,
1599            depth_m: 1.0,
1600            interior_blueprint: None,
1601            tags: vec![],
1602        });
1603        state.entities[0].transform.position = WorldCoord::surface(100.0, 200.0);
1604        state.player = state.entities.first().cloned();
1605        let view = WorldView::build_with_anchor(
1606            &state,
1607            21,
1608            15,
1609            12.3,
1610            40.7,
1611            None,
1612            WorldViewOptions::terrain_only(),
1613        );
1614        assert!((view.origin_x - 12.3).abs() < 0.01);
1615        assert!((view.origin_y - 40.7).abs() < 0.01);
1616    }
1617
1618    #[test]
1619    fn skip_local_player_glyph_when_paint_local_player_false() {
1620        let mut state = state_with_building(BuildingView {
1621            id: "x".into(),
1622            label: "X".into(),
1623            x: 10.0,
1624            y: 10.0,
1625            width_m: 1.0,
1626            depth_m: 1.0,
1627            interior_blueprint: None,
1628            tags: vec![],
1629        });
1630        state.entities[0].transform.position = WorldCoord::surface(128.0, 128.0);
1631        state.player = state.entities.first().cloned();
1632        let player_glyph = map_presentation::player_presentation().glyph;
1633
1634        let with_player = WorldView::build_with_options(
1635            &state,
1636            21,
1637            15,
1638            None,
1639            WorldViewOptions::default(),
1640        );
1641        let cx = with_player.width / 2;
1642        let cy = with_player.height / 2;
1643        assert_eq!(
1644            with_player.cells[cy * with_player.width + cx],
1645            player_glyph,
1646            "default build paints @"
1647        );
1648
1649        let without = WorldView::build_with_options(
1650            &state,
1651            21,
1652            15,
1653            None,
1654            WorldViewOptions::terrain_only(),
1655        );
1656        assert_ne!(
1657            without.cells[cy * without.width + cx],
1658            player_glyph,
1659            "gfx sprite mode must not paint local player @"
1660        );
1661    }
1662}