Skip to main content

flatland_client_ui/
world.rs

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