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