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