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            hud_log_hidden: false,
826            show_equip_menu: false,
827            equip_menu_index: 0,
828            ledger: None,
829            career: None,
830            character_sheet_tab: flatland_client_lib::CharacterSheetTab::Character,
831            ledger_period: flatland_client_lib::LedgerPeriod::Day,
832            show_craft_menu: false,
833            craft_menu_index: 0,
834            craft_batch_quantity: 1,
835            show_shop_menu: false,
836            shop_catalog: None,
837            bank_panel: None,
838            bank_menu_index: 0,
839            bank_ui_mode: flatland_client_lib::BankUiMode::Menu,
840            storage_panel: None,
841            storage_menu_index: 0,
842            storage_ui_mode: flatland_client_lib::StorageUiMode::Menu,
843            shop_tab: flatland_client_lib::ShopTab::default(),
844            shop_menu_index: 0,
845            shop_quantity: 1,
846            shop_trade_log: std::collections::VecDeque::new(),
847            show_npc_verb_menu: false,
848            npc_verb_target: None,
849            npc_verb_index: 0,
850            player_verbs: Default::default(),
851            social_chat: Default::default(),
852            trade_ui: Default::default(),
853            whisper_pouch_ui: Default::default(),
854            show_npc_chat: false,
855            npc_chat: None,
856            show_inventory_menu: false,
857            inventory_menu_index: 0,
858            inventory_tab: flatland_client_lib::InventoryTab::OnPerson,
859            inventory_filter: String::new(),
860            inventory_filter_focused: false,
861            show_move_picker: false,
862            show_rename_prompt: false,
863            show_worker_rename: false,
864            rename_buffer: String::new(),
865            move_picker_index: 0,
866            move_picker: None,
867            show_grant_picker: false,
868            grant_picker_index: 0,
869            grant_picker: None,
870            show_destroy_picker: false,
871            destroy_confirm_pending: false,
872            destroy_picker: None,
873            combat_target: None,
874            combat_target_label: None,
875            combat_fx: Vec::new(),
876            in_combat: false,
877            auto_attack: true,
878            combat_has_los: false,
879            attack_cd_ticks: 0,
880            gcd_ticks: 0,
881            weapon_ability_id: "unarmed".into(),
882            mainhand_template_id: None,
883            mainhand_label: None,
884            offhand_template_id: None,
885            offhand_label: None,
886            mainhand_hand_slots: 1,
887            defense: None,
888            worn: std::collections::BTreeMap::new(),
889            carry_mass: 0.0,
890            carry_mass_max: 0.0,
891            encumbrance: flatland_protocol::EncumbranceState::Light,
892            inventory_stacks: Vec::new(),
893            keychain_stacks: Vec::new(),
894            whisper_pouch_stacks: Vec::new(),
895            combat_target_detail: None,
896            statuses: Vec::new(),
897            cast_progress: None,
898            ability_cooldowns: Vec::new(),
899            blocking_active: false,
900            max_target_slots: 1,
901            combat_slots: Vec::new(),
902            rotation_presets: Vec::new(),
903            known_abilities: Vec::new(),
904            hotbar: vec![None; 9],
905            max_abilities_per_rotation: 0,
906            show_loadout_menu: false,
907            show_keychain_menu: false,
908            keychain_menu_index: 0,
909            show_rotation_editor: false,
910            loadout_menu_index: 0,
911            loadout_hotbar_slot: 1,
912            loadout_ability_index: 0,
913            loadout_focus_presets: false,
914            rotation_editor: Default::default(),
915            harvest_in_progress: false,
916            harvest_started_at: None,
917            pending_craft_ack: None,
918            pending_worker_job_ack: None,
919            quest_log: Vec::new(),
920            interactables: Vec::new(),
921            show_quest_offer: false,
922            pending_quest_offer: None,
923            show_quest_menu: false,
924            quest_menu_index: 0,
925            quest_withdraw_confirm: false,
926            hired_workers: Vec::new(),
927            show_workers_menu: false,
928            workers_menu_index: 0,
929            workers_menu_compact: false,
930            worker_step_display: std::collections::BTreeMap::new(),
931            worker_error_display: std::collections::BTreeMap::new(),
932            show_worker_give_picker: false,
933            worker_give_picker_index: 0,
934            worker_give_picker: None,
935            show_worker_give_target_picker: false,
936            worker_give_target_picker_index: 0,
937            worker_give_target_picker: None,
938            show_worker_take_picker: false,
939            worker_take_picker_index: 0,
940            worker_take_picker: None,
941            show_worker_teach_picker: false,
942            worker_teach_picker_index: 0,
943            worker_teach_picker: None,
944            worker_route_editor: None,
945            progression_curve: None,
946        }
947    }
948
949    #[test]
950    fn shallow_water_terrain_paints_tilde() {
951        use flatland_protocol::TerrainZoneView;
952        let mut state = state_with_building(BuildingView {
953            id: "x".into(),
954            label: "X".into(),
955            x: 128.0,
956            y: 128.0,
957            width_m: 1.0,
958            depth_m: 1.0,
959            interior_blueprint: None,
960            tags: vec![],
961        });
962        state.terrain_zones.push(TerrainZoneView {
963            id: "pond".into(),
964            x0: 126.0,
965            y0: 126.0,
966            x1: 130.0,
967            y1: 130.0,
968            kind: TerrainKindView::ShallowWater,
969            elevation: -0.5,
970            glyph: None,
971            color: None,
972            tile_id: None,
973            z_order: 0,
974        });
975        state.entities = vec![EntityState {
976            id: 1,
977            label: "You".into(),
978            transform: Transform {
979                position: WorldCoord::surface(128.0, 128.0),
980                yaw: 0.0,
981                velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
982            },
983            vitals: Some(PlayerVitals::default()),
984            attributes: Some(PrimaryAttributes::default()),
985            skills: Some(flatland_protocol::PlayerSkills::default()),
986            inside_building: None,
987            tile_id: None,
988            paperdoll_ref: None,
989            presentation_state: None,
990            sprite_mode: None,
991            progression_xp: None,
992        }];
993        state.player = state.entities.first().cloned();
994        let view = WorldView::build_with_target(&state, 9, 9, None);
995        let flat = view.cells.join("");
996        let water = map_presentation::shallow_water_presentation();
997        assert!(
998            flat.contains(&water.glyph),
999            "expected water tiles ({:?}): {flat}",
1000            water.glyph
1001        );
1002        assert!(
1003            view.cell_fg.iter().any(|c| *c == Some(water.color)),
1004            "expected water color on terrain cells"
1005        );
1006    }
1007
1008    #[test]
1009    fn zone_glyph_and_color_overrides_paint_on_map() {
1010        use flatland_protocol::TerrainZoneView;
1011        let mut state = state_with_building(BuildingView {
1012            id: "x".into(),
1013            label: "X".into(),
1014            x: 128.0,
1015            y: 128.0,
1016            width_m: 1.0,
1017            depth_m: 1.0,
1018            interior_blueprint: None,
1019            tags: vec![],
1020        });
1021        state.terrain_zones.push(TerrainZoneView {
1022            id: "marked".into(),
1023            x0: 126.0,
1024            y0: 126.0,
1025            x1: 130.0,
1026            y1: 130.0,
1027            kind: TerrainKindView::Grass,
1028            elevation: 0.0,
1029            glyph: Some("%".into()),
1030            color: Some("magenta".into()),
1031            tile_id: None,
1032            z_order: 0,
1033        });
1034        state.entities = vec![EntityState {
1035            id: 1,
1036            label: "You".into(),
1037            transform: Transform {
1038                position: WorldCoord::surface(128.0, 128.0),
1039                yaw: 0.0,
1040                velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
1041            },
1042            vitals: Some(PlayerVitals::default()),
1043            attributes: Some(PrimaryAttributes::default()),
1044            skills: Some(flatland_protocol::PlayerSkills::default()),
1045            inside_building: None,
1046            tile_id: None,
1047            paperdoll_ref: None,
1048            presentation_state: None,
1049            sprite_mode: None,
1050            progression_xp: None,
1051        }];
1052        state.player = state.entities.first().cloned();
1053        let view = WorldView::build_with_target(&state, 9, 9, None);
1054        let flat = view.cells.join("");
1055        assert!(
1056            flat.contains('%'),
1057            "expected custom zone glyph on map: {flat}"
1058        );
1059        assert!(
1060            view.cell_fg.iter().any(|c| *c == Some(RgbColor::MAGENTA)),
1061            "expected custom zone color on terrain cells"
1062        );
1063    }
1064
1065    #[test]
1066    fn well_paints_water_ring_and_center() {
1067        let building = BuildingView {
1068            id: "town_well".into(),
1069            label: "Well".into(),
1070            x: 122.0,
1071            y: 106.0,
1072            width_m: 3.0,
1073            depth_m: 3.0,
1074            interior_blueprint: None,
1075            tags: vec!["well".into()],
1076        };
1077        let mut state = state_with_building(building);
1078        state.entities = vec![EntityState {
1079            id: 1,
1080            label: "You".into(),
1081            transform: Transform {
1082                position: WorldCoord::surface(120.0, 106.0),
1083                yaw: 0.0,
1084                velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
1085            },
1086            vitals: Some(PlayerVitals::default()),
1087            attributes: Some(PrimaryAttributes::default()),
1088            skills: Some(flatland_protocol::PlayerSkills::default()),
1089            inside_building: None,
1090            tile_id: None,
1091            paperdoll_ref: None,
1092            presentation_state: None,
1093            sprite_mode: None,
1094            progression_xp: None,
1095        }];
1096        state.player = state.entities.first().cloned();
1097        let view = WorldView::build_with_target(&state, 9, 9, None);
1098        let flat = view.cells.join("");
1099        assert!(flat.contains('~'), "expected well water: {flat}");
1100        assert!(flat.contains('O'), "expected well center: {flat}");
1101    }
1102
1103    #[test]
1104    fn broker_hut_draws_wall_outline() {
1105        let building = BuildingView {
1106            id: "broker_hut".into(),
1107            label: "Broker's Hut".into(),
1108            x: 148.0,
1109            y: 118.0,
1110            width_m: 8.0,
1111            depth_m: 6.0,
1112            interior_blueprint: None,
1113            tags: vec![],
1114        };
1115        let mut state = state_with_building(building);
1116        state.player = state.entities.first().cloned();
1117        let view = WorldView::build_with_target(&state, 25, 15, None);
1118        let flat = view.cells.join("");
1119        assert!(flat.contains('+'), "expected corners: {flat}");
1120        assert!(flat.contains('-'), "expected horiz walls: {flat}");
1121        assert!(flat.contains('|'), "expected vert walls: {flat}");
1122    }
1123
1124    #[test]
1125    fn interior_map_renders_rooms_and_walls() {
1126        use flatland_protocol::{InteriorMapView, InteriorRoomView};
1127        let building = BuildingView {
1128            id: "broker_hut".into(),
1129            label: "Broker's Hut".into(),
1130            x: 148.0,
1131            y: 118.0,
1132            width_m: 8.0,
1133            depth_m: 6.0,
1134            interior_blueprint: Some("broker_hut".into()),
1135            tags: vec![],
1136        };
1137        let mut state = state_with_building(building);
1138        state.interior_map = Some(InteriorMapView {
1139            building_id: "broker_hut".into(),
1140            blueprint_id: "broker_hut".into(),
1141            background_color: "#000000".into(),
1142            default_floor_color: Some("#2a2a2a".into()),
1143            floor_height_m: 3.0,
1144            rooms: vec![InteriorRoomView {
1145                id: "main".into(),
1146                label: "Main".into(),
1147                floor: 0,
1148                x0: 0.0,
1149                y0: 0.0,
1150                x1: 8.5,
1151                y1: 7.0,
1152                floor_color: None,
1153                floor_glyph: None,
1154            }],
1155            room_doors: vec![],
1156        });
1157        state.entities = vec![EntityState {
1158            id: 1,
1159            label: "You".into(),
1160            transform: Transform {
1161                position: WorldCoord::surface(4.0, 3.0),
1162                yaw: 0.0,
1163                velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
1164            },
1165            vitals: Some(PlayerVitals::default()),
1166            attributes: Some(PrimaryAttributes::default()),
1167            skills: Some(flatland_protocol::PlayerSkills::default()),
1168            inside_building: Some("broker_hut".into()),
1169            tile_id: None,
1170            paperdoll_ref: None,
1171            presentation_state: None,
1172            sprite_mode: None,
1173            progression_xp: None,
1174        }];
1175        state.player = state.entities.first().cloned();
1176        let view = WorldView::build_with_target(&state, 25, 15, None);
1177        assert_eq!(view.inside_building.as_deref(), Some("broker_hut"));
1178        let flat = view.cells.join("");
1179        assert!(flat.contains('+'), "expected interior walls: {flat}");
1180        assert!(flat.contains('@'), "expected player marker: {flat}");
1181    }
1182
1183    #[test]
1184    fn stale_interior_map_renders_outdoor_when_outside() {
1185        use flatland_protocol::{InteriorMapView, InteriorRoomView};
1186        let building = BuildingView {
1187            id: "town_hall".into(),
1188            label: "Town Hall".into(),
1189            x: 163.0,
1190            y: 137.0,
1191            width_m: 20.0,
1192            depth_m: 10.0,
1193            interior_blueprint: Some("town_hall".into()),
1194            tags: vec![],
1195        };
1196        let mut state = state_with_building(building);
1197        state.interior_map = Some(InteriorMapView {
1198            building_id: "town_hall".into(),
1199            blueprint_id: "town_hall".into(),
1200            background_color: "#000000".into(),
1201            default_floor_color: Some("#2a2a2a".into()),
1202            floor_height_m: 3.0,
1203            rooms: vec![InteriorRoomView {
1204                id: "main_hall".into(),
1205                label: "Main".into(),
1206                floor: 0,
1207                x0: -3.5,
1208                y0: -8.0,
1209                x1: 18.5,
1210                y1: 6.0,
1211                floor_color: None,
1212                floor_glyph: None,
1213            }],
1214            room_doors: vec![],
1215        });
1216        state.player = state.entities.first().cloned();
1217        let view = WorldView::build_with_target(&state, 25, 15, None);
1218        assert!(view.inside_building.is_none());
1219        let flat = view.cells.join("");
1220        let grass = map_presentation::terrain_for(TerrainKindView::Grass).glyph;
1221        assert!(
1222            flat.contains(&grass),
1223            "expected outdoor terrain, not stale interior background: {flat}"
1224        );
1225        assert!(
1226            !flat.chars().all(|c| c == ' ' || c == '@'),
1227            "stale interior_map must not paint black interior when outside"
1228        );
1229    }
1230
1231    #[test]
1232    fn stale_inside_flag_still_renders_outdoor_world() {
1233        use flatland_protocol::ResourceNodeState;
1234        let building = BuildingView {
1235            id: "broker_hut".into(),
1236            label: "Broker's Hut".into(),
1237            x: 148.0,
1238            y: 118.0,
1239            width_m: 8.0,
1240            depth_m: 6.0,
1241            interior_blueprint: None,
1242            tags: vec![],
1243        };
1244        let mut state = state_with_building(building);
1245        state.entities = vec![EntityState {
1246            id: 1,
1247            label: "You".into(),
1248            transform: Transform {
1249                position: WorldCoord::surface(128.0, 128.0),
1250                yaw: 0.0,
1251                velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
1252            },
1253            vitals: Some(PlayerVitals::default()),
1254            attributes: Some(PrimaryAttributes::default()),
1255            skills: Some(flatland_protocol::PlayerSkills::default()),
1256            inside_building: Some("broker_hut".into()),
1257            tile_id: None,
1258            paperdoll_ref: None,
1259            presentation_state: None,
1260            sprite_mode: None,
1261            progression_xp: None,
1262        }];
1263        state.player = state.entities.first().cloned();
1264        state
1265            .resource_nodes
1266            .push(flatland_protocol::ResourceNodeView {
1267                id: "oak".into(),
1268                label: "Oak".into(),
1269                x: 126.0,
1270                y: 134.0,
1271                z: 0.0,
1272                item_template: "oak_log".into(),
1273                state: ResourceNodeState::Available,
1274                blocking: true,
1275                blocking_radius_m: 0.8,
1276                tile_id: None,
1277                paperdoll_ref: None,
1278                yaw: 0.0,
1279                pitch: 0.0,
1280                roll: 0.0,
1281                draw_scale: 1.0,
1282                sprite_mode: None,
1283                presentation_state: None,
1284            });
1285        let view = WorldView::build_with_target(&state, 25, 15, None);
1286        assert_eq!(
1287            view.inside_building.as_deref(),
1288            Some("broker_hut"),
1289            "server inside flag is authoritative"
1290        );
1291        let flat = view.cells.join("");
1292        let oak = map_presentation::resource_for(&flatland_protocol::ResourceNodeView {
1293            id: "oak".into(),
1294            label: "Oak".into(),
1295            x: 0.0,
1296            y: 0.0,
1297            z: 0.0,
1298            item_template: "oak_log".into(),
1299            state: flatland_protocol::ResourceNodeState::Available,
1300            blocking: true,
1301            blocking_radius_m: 0.8,
1302            tile_id: None,
1303            paperdoll_ref: None,
1304            yaw: 0.0,
1305            pitch: 0.0,
1306            roll: 0.0,
1307            draw_scale: 1.0,
1308            sprite_mode: None,
1309            presentation_state: None,
1310        });
1311        assert!(
1312            flat.contains(&oak.glyph),
1313            "expected nearby tree ({:?}): {flat}",
1314            oak.glyph
1315        );
1316        assert!(flat.contains('@'), "expected player: {flat}");
1317    }
1318
1319    #[test]
1320    fn inside_building_flag_selects_active_instance() {
1321        let town = BuildingView {
1322            id: "town_hall".into(),
1323            label: "Town Hall".into(),
1324            x: 160.0,
1325            y: 136.0,
1326            width_m: 20.0,
1327            depth_m: 10.0,
1328            interior_blueprint: Some("town_hall".into()),
1329            tags: vec![],
1330        };
1331        let guild = BuildingView {
1332            id: "guild_hall".into(),
1333            label: "Guild Hall".into(),
1334            x: 164.0,
1335            y: 152.0,
1336            width_m: 20.0,
1337            depth_m: 14.0,
1338            interior_blueprint: Some("guild_hall".into()),
1339            tags: vec![],
1340        };
1341        let mut state = state_with_building(town);
1342        state.buildings.push(guild);
1343        state.entities = vec![EntityState {
1344            id: 1,
1345            label: "You".into(),
1346            transform: Transform {
1347                position: WorldCoord::surface(4.0, 3.0),
1348                yaw: 0.0,
1349                velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
1350            },
1351            vitals: Some(PlayerVitals::default()),
1352            attributes: Some(PrimaryAttributes::default()),
1353            skills: Some(flatland_protocol::PlayerSkills::default()),
1354            inside_building: Some("guild_hall".into()),
1355            tile_id: None,
1356            paperdoll_ref: None,
1357            presentation_state: None,
1358            sprite_mode: None,
1359            progression_xp: None,
1360        }];
1361        state.player = state.entities.first().cloned();
1362        state.world_width_m = 256.0;
1363        state.world_height_m = 256.0;
1364
1365        let view = WorldView::build_with_target(&state, 25, 15, None);
1366        assert_eq!(view.inside_building.as_deref(), Some("guild_hall"));
1367    }
1368
1369    #[test]
1370    fn combat_target_marks_creature_cell() {
1371        let building = BuildingView {
1372            id: "x".into(),
1373            label: "X".into(),
1374            x: 128.0,
1375            y: 128.0,
1376            width_m: 1.0,
1377            depth_m: 1.0,
1378            interior_blueprint: None,
1379            tags: vec![],
1380        };
1381        let mut state = state_with_building(building);
1382        state.entities = vec![
1383            EntityState {
1384                id: 1,
1385                label: "You".into(),
1386                transform: Transform {
1387                    position: WorldCoord::surface(100.0, 100.0),
1388                    yaw: 0.0,
1389                    velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
1390                },
1391                vitals: Some(PlayerVitals::default()),
1392                attributes: Some(PrimaryAttributes::default()),
1393                skills: Some(flatland_protocol::PlayerSkills::default()),
1394                inside_building: None,
1395                tile_id: None,
1396                paperdoll_ref: None,
1397                presentation_state: None,
1398                sprite_mode: None,
1399                progression_xp: None,
1400            },
1401            EntityState {
1402                id: 42,
1403                label: "Rabbit".into(),
1404                transform: Transform {
1405                    position: WorldCoord::surface(103.0, 100.0),
1406                    yaw: 0.0,
1407                    velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
1408                },
1409                vitals: None,
1410                attributes: None,
1411                skills: None,
1412                inside_building: None,
1413                tile_id: None,
1414                paperdoll_ref: None,
1415                presentation_state: None,
1416                sprite_mode: None,
1417                progression_xp: None,
1418            },
1419        ];
1420        state.player = state.entities.first().cloned();
1421        state.combat_target = Some(42);
1422        state.combat_target_label = Some("Rabbit".into());
1423
1424        let view = WorldView::build_with_target(&state, 25, 15, None);
1425        let marked: usize = view
1426            .target_t1_cells
1427            .iter()
1428            .chain(view.target_t2_cells.iter())
1429            .filter(|b| **b)
1430            .count();
1431        assert_eq!(marked, 1, "exactly one targeted cell");
1432        let idx = view
1433            .target_t1_cells
1434            .iter()
1435            .chain(view.target_t2_cells.iter())
1436            .position(|b| *b)
1437            .expect("target cell");
1438        assert_eq!(view.cells[idx], "R");
1439    }
1440
1441    #[test]
1442    fn combat_target_ring_prefers_live_npc_coords() {
1443        let building = BuildingView {
1444            id: "x".into(),
1445            label: "X".into(),
1446            x: 128.0,
1447            y: 128.0,
1448            width_m: 1.0,
1449            depth_m: 1.0,
1450            interior_blueprint: None,
1451            tags: vec![],
1452        };
1453        let mut state = state_with_building(building);
1454        state.entities = vec![
1455            EntityState {
1456                id: 1,
1457                label: "You".into(),
1458                transform: Transform {
1459                    position: WorldCoord::surface(100.0, 100.0),
1460                    yaw: 0.0,
1461                    velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
1462                },
1463                vitals: Some(PlayerVitals::default()),
1464                attributes: Some(PrimaryAttributes::default()),
1465                skills: Some(flatland_protocol::PlayerSkills::default()),
1466                inside_building: None,
1467                tile_id: None,
1468                paperdoll_ref: None,
1469                presentation_state: None,
1470                sprite_mode: None,
1471                progression_xp: None,
1472            },
1473            EntityState {
1474                id: 42,
1475                label: "Rabbit".into(),
1476                // Stale entity transform — NPC view has the live position.
1477                transform: Transform {
1478                    position: WorldCoord::surface(90.0, 100.0),
1479                    yaw: 0.0,
1480                    velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
1481                },
1482                vitals: None,
1483                attributes: None,
1484                skills: None,
1485                inside_building: None,
1486                tile_id: None,
1487                paperdoll_ref: None,
1488                presentation_state: None,
1489                sprite_mode: None,
1490                progression_xp: None,
1491            },
1492        ];
1493        state.npcs = vec![flatland_protocol::NpcView {
1494            id: "rabbit-1".into(),
1495            label: "Rabbit".into(),
1496            role: "wildlife".into(),
1497            x: 103.0,
1498            y: 100.0,
1499            building_id: None,
1500            entity_id: Some(42),
1501            life_state: Some(flatland_protocol::LifeState::Alive),
1502            hp_pct: Some(1.0),
1503            can_trade: false,
1504            tile_id: None,
1505            behavior_state: None,
1506            presentation_state: None,
1507            sprite_mode: None,
1508            paperdoll_ref: None,
1509        }];
1510        state.player = state.entities.first().cloned();
1511        state.combat_target = Some(42);
1512
1513        let view = WorldView::build_with_target(&state, 25, 15, None);
1514        let marked = view
1515            .target_t1_cells
1516            .iter()
1517            .position(|b| *b)
1518            .expect("target cell");
1519        let half_w = (view.width / 2) as i32;
1520        let half_h = (view.height / 2) as i32;
1521        let (live_gx, live_gy) =
1522            world_to_grid(103.0, 100.0, 100.0, 100.0, half_w, half_h, view.width, view.height)
1523                .expect("live npc in view");
1524        let (stale_gx, stale_gy) =
1525            world_to_grid(90.0, 100.0, 100.0, 100.0, half_w, half_h, view.width, view.height)
1526                .expect("stale entity in view");
1527        let live_idx = live_gy * view.width + live_gx;
1528        let stale_idx = stale_gy * view.width + stale_gx;
1529        assert_eq!(marked, live_idx, "ring must follow live NPC coords");
1530        assert_ne!(marked, stale_idx, "ring must not stay on stale entity coords");
1531    }
1532
1533    #[test]
1534    fn grid_to_world_roundtrips_center() {
1535        let px = 10.0;
1536        let py = 20.0;
1537        let view_w = 11;
1538        let view_h = 11;
1539        let half_w = (view_w / 2) as i32;
1540        let half_h = (view_h / 2) as i32;
1541        let (gx, gy) =
1542            world_to_grid(12.0, 18.0, px, py, half_w, half_h, view_w, view_h).expect("in view");
1543        let (wx, wy) = grid_to_world(gx, gy, px, py, view_w, view_h).expect("inverse");
1544        assert!((wx - 12.0).abs() < 0.01);
1545        assert!((wy - 18.0).abs() < 0.01);
1546    }
1547
1548    #[test]
1549    fn vertical_axis_quantizes_to_one_meter() {
1550        let px = 0.0;
1551        let py = 0.0;
1552        let view_w = 21;
1553        let view_h = 21;
1554        let half_w = (view_w / 2) as i32;
1555        let half_h = (view_h / 2) as i32;
1556        let (gx, gy) =
1557            world_to_grid(3.0, 3.0, px, py, half_w, half_h, view_w, view_h).expect("in view");
1558        let (wx, wy) = grid_to_world(gx, gy, px, py, view_w, view_h).expect("inverse");
1559        assert!(
1560            (wx - 3.0).abs() < 0.01,
1561            "x should stay exact to 1m: got {wx}"
1562        );
1563        assert!(
1564            (wy - 3.0).abs() < 0.01,
1565            "y should stay exact to 1m: got {wy}"
1566        );
1567    }
1568
1569    #[test]
1570    fn square_extent_spans_equal_rows_and_columns() {
1571        let px = 0.0;
1572        let py = 0.0;
1573        let half_w = 50;
1574        let half_h = 50;
1575        let width = 101;
1576        let height = 101;
1577        let (gx0, gy0) =
1578            world_to_grid(-4.0, -4.0, px, py, half_w, half_h, width, height).expect("in view");
1579        let (gx1, gy1) =
1580            world_to_grid(4.0, 4.0, px, py, half_w, half_h, width, height).expect("in view");
1581        let cols_spanned = (gx1 as i32 - gx0 as i32).unsigned_abs();
1582        let rows_spanned = (gy1 as i32 - gy0 as i32).unsigned_abs();
1583        assert_eq!(cols_spanned, 8, "8m wide should span 8 columns");
1584        assert_eq!(rows_spanned, 8, "8m tall should span 8 rows");
1585    }
1586
1587    #[test]
1588    fn resource_paints_on_terrain_cell_for_same_world_coords() {
1589        use flatland_protocol::{ResourceNodeState, ResourceNodeView, TerrainZoneView};
1590
1591        let px = 128.0;
1592        let py = 128.0;
1593        let rx = 131.0;
1594        let ry = 132.0;
1595        let mut state = state_with_building(BuildingView {
1596            id: "x".into(),
1597            label: "X".into(),
1598            x: 128.0,
1599            y: 128.0,
1600            width_m: 1.0,
1601            depth_m: 1.0,
1602            interior_blueprint: None,
1603            tags: vec![],
1604        });
1605        state.terrain_zones.push(TerrainZoneView {
1606            id: "pond".into(),
1607            x0: rx,
1608            y0: ry,
1609            x1: rx + 1.0,
1610            y1: ry + 1.0,
1611            kind: TerrainKindView::ShallowWater,
1612            elevation: -0.5,
1613            glyph: None,
1614            color: None,
1615            tile_id: None,
1616            z_order: 0,
1617        });
1618        state.resource_nodes.push(ResourceNodeView {
1619            id: "oak".into(),
1620            label: "Oak".into(),
1621            x: rx,
1622            y: ry,
1623            z: 0.0,
1624            item_template: "oak_log".into(),
1625            state: ResourceNodeState::Available,
1626            blocking: true,
1627            blocking_radius_m: 0.8,
1628            tile_id: None,
1629            paperdoll_ref: None,
1630            yaw: 0.0,
1631            pitch: 0.0,
1632            roll: 0.0,
1633            draw_scale: 1.0,
1634            sprite_mode: None,
1635            presentation_state: None,
1636        });
1637        state.entities[0].transform.position = WorldCoord::surface(px, py);
1638        state.player = state.entities.first().cloned();
1639
1640        let view = WorldView::build_with_target(&state, 25, 15, None);
1641        let half_w = (view.width / 2) as i32;
1642        let half_h = (view.height / 2) as i32;
1643        let (gx, gy) = world_to_grid(rx, ry, px, py, half_w, half_h, view.width, view.height)
1644            .expect("resource in view");
1645        let idx = gy * view.width + gx;
1646        let oak = map_presentation::resource_for(&state.resource_nodes[0]);
1647        assert_eq!(
1648            view.cells[idx], oak.glyph,
1649            "resource should paint on the grid cell for its world coords"
1650        );
1651        let (wx, wy) = grid_to_world(gx, gy, px, py, view.width, view.height).expect("inverse");
1652        assert!(
1653            (wx - rx).abs() < 0.01 && (wy - ry).abs() < 0.01,
1654            "resource grid cell should sample terrain at ({wx}, {wy}), expected ({rx}, {ry})"
1655        );
1656    }
1657
1658    #[test]
1659    fn chest_and_loot_paint_on_non_grass_terrain() {
1660        use flatland_protocol::{GroundDropView, PlacedContainerView, TerrainZoneView};
1661
1662        let px = 50.0;
1663        let py = 50.0;
1664        let cx = 53.0;
1665        let cy = 52.0;
1666        let lx = 54.0;
1667        let ly = 52.0;
1668        let mut state = state_with_building(BuildingView {
1669            id: "x".into(),
1670            label: "X".into(),
1671            x: 50.0,
1672            y: 50.0,
1673            width_m: 1.0,
1674            depth_m: 1.0,
1675            interior_blueprint: None,
1676            tags: vec![],
1677        });
1678        state.terrain_zones.push(TerrainZoneView {
1679            id: "trail".into(),
1680            x0: 52.0,
1681            y0: 51.0,
1682            x1: 56.0,
1683            y1: 54.0,
1684            kind: TerrainKindView::Trail,
1685            elevation: 0.0,
1686            glyph: None,
1687            color: None,
1688            tile_id: None,
1689            z_order: 0,
1690        });
1691        state.placed_containers.push(PlacedContainerView {
1692            id: "chest_1".into(),
1693            template_id: "wood_chest".into(),
1694            display_name: "Storage".into(),
1695            x: cx,
1696            y: cy,
1697            z: 0.0,
1698            locked: false,
1699            accessible: true,
1700            owner_character_id: None,
1701            contents: vec![],
1702            lock_id: None,
1703            capacity_volume: Some(40.0),
1704            item_instance_id: None,
1705            tile_id: None,
1706            paperdoll_ref: None,
1707            worker_lodging_capacity: None,
1708        });
1709        state.ground_drops.push(GroundDropView {
1710            id: "drop_1".into(),
1711            template_id: "lumber".into(),
1712            quantity: 2,
1713            x: lx,
1714            y: ly,
1715            z: 0.0,
1716            tile_id: None,
1717            paperdoll_ref: None,
1718            yaw: 0.0,
1719            pitch: 0.0,
1720            roll: 0.0,
1721            draw_scale: 1.0,
1722        });
1723        state.entities[0].transform.position = WorldCoord::surface(px, py);
1724        state.player = state.entities.first().cloned();
1725
1726        let view = WorldView::build_with_target(&state, 25, 15, None);
1727        let half_w = (view.width / 2) as i32;
1728        let half_h = (view.height / 2) as i32;
1729        let (gx, gy) =
1730            world_to_grid(cx, cy, px, py, half_w, half_h, view.width, view.height).expect("chest");
1731        let chest = map_presentation::chest_presentation(false);
1732        assert_eq!(
1733            view.cells[gy * view.width + gx],
1734            chest.glyph,
1735            "chest must paint on trail/non-grass cells"
1736        );
1737        let (gx2, gy2) =
1738            world_to_grid(lx, ly, px, py, half_w, half_h, view.width, view.height).expect("loot");
1739        let loot = map_presentation::loot_presentation();
1740        assert_eq!(
1741            view.cells[gy2 * view.width + gx2],
1742            loot.glyph,
1743            "ground loot must paint on trail/non-grass cells"
1744        );
1745    }
1746
1747    #[test]
1748    fn build_with_anchor_sets_view_origin() {
1749        let mut state = state_with_building(BuildingView {
1750            id: "x".into(),
1751            label: "X".into(),
1752            x: 128.0,
1753            y: 128.0,
1754            width_m: 1.0,
1755            depth_m: 1.0,
1756            interior_blueprint: None,
1757            tags: vec![],
1758        });
1759        state.entities[0].transform.position = WorldCoord::surface(100.0, 200.0);
1760        state.player = state.entities.first().cloned();
1761        let view = WorldView::build_with_anchor(
1762            &state,
1763            21,
1764            15,
1765            12.3,
1766            40.7,
1767            None,
1768            WorldViewOptions::terrain_only(),
1769        );
1770        assert!((view.origin_x - 12.3).abs() < 0.01);
1771        assert!((view.origin_y - 40.7).abs() < 0.01);
1772    }
1773
1774    #[test]
1775    fn skip_local_player_glyph_when_paint_local_player_false() {
1776        let mut state = state_with_building(BuildingView {
1777            id: "x".into(),
1778            label: "X".into(),
1779            x: 10.0,
1780            y: 10.0,
1781            width_m: 1.0,
1782            depth_m: 1.0,
1783            interior_blueprint: None,
1784            tags: vec![],
1785        });
1786        state.entities[0].transform.position = WorldCoord::surface(128.0, 128.0);
1787        state.player = state.entities.first().cloned();
1788        let player_glyph = map_presentation::player_presentation().glyph;
1789
1790        let with_player =
1791            WorldView::build_with_options(&state, 21, 15, None, WorldViewOptions::default());
1792        let cx = with_player.width / 2;
1793        let cy = with_player.height / 2;
1794        assert_eq!(
1795            with_player.cells[cy * with_player.width + cx],
1796            player_glyph,
1797            "default build paints @"
1798        );
1799
1800        let without =
1801            WorldView::build_with_options(&state, 21, 15, None, WorldViewOptions::terrain_only());
1802        assert_ne!(
1803            without.cells[cy * without.width + cx],
1804            player_glyph,
1805            "gfx sprite mode must not paint local player @"
1806        );
1807    }
1808}