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),
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 }],
1020 player: None,
1021 resource_nodes: vec![],
1022 ground_drops: vec![],
1023 placed_containers: vec![],
1024 buildings: vec![building],
1025 doors: vec![],
1026 interior_map: None,
1027 npcs: vec![],
1028 blueprints: vec![],
1029 world_x0: 0.0,
1030 world_y0: 0.0,
1031 world_width_m: 256.0,
1032 world_height_m: 256.0,
1033 terrain_zones: vec![],
1034 z_platforms: vec![],
1035 z_transitions: vec![],
1036 world_clock: flatland_protocol::WorldClock::default(),
1037 inventory: Default::default(),
1038 inventory_hints: Default::default(),
1039 logs: Default::default(),
1040 intents_sent: 0,
1041 ticks_received: 0,
1042 connected: true,
1043 disconnect_reason: None,
1044 show_stats: false,
1045 hud_log_hidden: false,
1046 show_equip_menu: false,
1047 equip_menu_index: 0,
1048 ledger: None,
1049 career: None,
1050 character_sheet_tab: flatland_client_lib::CharacterSheetTab::Character,
1051 ledger_period: flatland_client_lib::LedgerPeriod::Day,
1052 show_craft_menu: false,
1053 craft_menu_index: 0,
1054 craft_batch_quantity: 1,
1055 show_shop_menu: false,
1056 shop_catalog: None,
1057 bank_panel: None,
1058 bank_menu_index: 0,
1059 bank_ui_mode: flatland_client_lib::BankUiMode::Menu,
1060 storage_panel: None,
1061 market_panel: None,
1062 market_menu_index: 0,
1063 market_filter: String::new(),
1064 market_filter_focused: false,
1065 market_category_filter: None,
1066 market_buy_confirm: None,
1067 market_ui_mode: flatland_client_lib::MarketUiMode::Browse,
1068 storage_menu_index: 0,
1069 storage_ui_mode: flatland_client_lib::StorageUiMode::Menu,
1070 property_zones: vec![],
1071 tax_zones: vec![],
1072 growth_zones: vec![],
1073 biome_zones: vec![],
1074 property_plots: vec![],
1075 property_plot_settings: None,
1076 claim_mode: None,
1077 relocate_mode: None,
1078 sell_plot_confirm: None,
1079 sell_plot_armed_at: None,
1080 show_plant_menu: false,
1081 plant_menu_index: 0,
1082 show_farm_access: false,
1083 farm_access_name_draft: String::new(),
1084 farm_access_discount_bps: 0,
1085 farm_access_index: 0,
1086 plant_quantity: 1,
1087 shop_tab: flatland_client_lib::ShopTab::default(),
1088 shop_menu_index: 0,
1089 shop_quantity: 1,
1090 shop_trade_log: std::collections::VecDeque::new(),
1091 show_npc_verb_menu: false,
1092 npc_verb_target: None,
1093 npc_verb_index: 0,
1094 player_verbs: Default::default(),
1095 social_chat: Default::default(),
1096 trade_ui: Default::default(),
1097 whisper_pouch_ui: Default::default(),
1098 show_npc_chat: false,
1099 npc_chat: None,
1100 show_inventory_menu: false,
1101 inventory_menu_index: 0,
1102 inventory_tab: flatland_client_lib::InventoryTab::OnPerson,
1103 inventory_filter: String::new(),
1104 inventory_filter_focused: false,
1105 show_move_picker: false,
1106 show_rename_prompt: false,
1107 show_worker_rename: false,
1108 rename_buffer: String::new(),
1109 move_picker_index: 0,
1110 move_picker: None,
1111 show_grant_picker: false,
1112 grant_picker_index: 0,
1113 grant_picker: None,
1114 show_destroy_picker: false,
1115 destroy_confirm_pending: false,
1116 destroy_picker: None,
1117 combat_target: None,
1118 combat_target_label: None,
1119 combat_fx: Vec::new(),
1120 in_combat: false,
1121 auto_attack: true,
1122 combat_has_los: false,
1123 attack_cd_ticks: 0,
1124 gcd_ticks: 0,
1125 weapon_ability_id: "unarmed".into(),
1126 mainhand_template_id: None,
1127 mainhand_label: None,
1128 offhand_template_id: None,
1129 offhand_label: None,
1130 mainhand_hand_slots: 1,
1131 defense: None,
1132 worn: std::collections::BTreeMap::new(),
1133 carry_mass: 0.0,
1134 carry_mass_max: 0.0,
1135 encumbrance: flatland_protocol::EncumbranceState::Light,
1136 inventory_stacks: Vec::new(),
1137 keychain_stacks: Vec::new(),
1138 whisper_pouch_stacks: Vec::new(),
1139 combat_target_detail: None,
1140 statuses: Vec::new(),
1141 cast_progress: None,
1142 timed_channel: None,
1143 ability_cooldowns: Vec::new(),
1144 blocking_active: false,
1145 max_target_slots: 1,
1146 combat_slots: Vec::new(),
1147 rotation_presets: Vec::new(),
1148 known_abilities: Vec::new(),
1149 hotbar: vec![None; 9],
1150 max_abilities_per_rotation: 0,
1151 show_loadout_menu: false,
1152 show_keychain_menu: false,
1153 keychain_menu_index: 0,
1154 show_rotation_editor: false,
1155 loadout_menu_index: 0,
1156 loadout_hotbar_slot: 1,
1157 loadout_ability_index: 0,
1158 loadout_focus_presets: false,
1159 rotation_editor: Default::default(),
1160 harvest_in_progress: false,
1161 harvest_started_at: None,
1162 pending_craft_ack: None,
1163 pending_worker_job_ack: None,
1164 attending_worker_instance_id: None,
1165 quest_log: Vec::new(),
1166 interactables: Vec::new(),
1167 show_quest_offer: false,
1168 pending_quest_offer: None,
1169 show_quest_menu: false,
1170 quest_menu_index: 0,
1171 quest_withdraw_confirm: false,
1172 hired_workers: Vec::new(),
1173 show_workers_menu: false,
1174 workers_menu_index: 0,
1175 workers_menu_compact: false,
1176 worker_step_display: std::collections::BTreeMap::new(),
1177 worker_error_display: std::collections::BTreeMap::new(),
1178 show_worker_give_picker: false,
1179 worker_give_picker_index: 0,
1180 worker_give_picker: None,
1181 show_worker_give_target_picker: false,
1182 worker_give_target_picker_index: 0,
1183 worker_give_target_picker: None,
1184 show_worker_take_picker: false,
1185 worker_take_picker_index: 0,
1186 worker_take_picker: None,
1187 show_worker_teach_picker: false,
1188 worker_teach_picker_index: 0,
1189 worker_teach_picker: None,
1190 worker_route_editor: None,
1191 progression_curve: None,
1192 }
1193 }
1194
1195 #[test]
1196 fn shallow_water_terrain_paints_tilde() {
1197 use flatland_protocol::TerrainZoneView;
1198 let mut state = state_with_building(BuildingView {
1199 id: "x".into(),
1200 label: "X".into(),
1201 x: 128.0,
1202 y: 128.0,
1203 width_m: 1.0,
1204 depth_m: 1.0,
1205 interior_blueprint: None,
1206 tags: vec![],
1207 market_boundary_zone_ids: vec![],
1208 market_max_volume: None,
1209 wall_set: None,
1210 roof_set: None,
1211 });
1212 state.terrain_zones.push(TerrainZoneView {
1213 id: "pond".into(),
1214 x0: 126.0,
1215 y0: 126.0,
1216 x1: 130.0,
1217 y1: 130.0,
1218 kind: TerrainKindView::ShallowWater,
1219 elevation: -0.5,
1220 glyph: None,
1221 color: None,
1222 tile_id: None,
1223 z_order: 0,
1224 channel_start_tick: None,
1225 channel_end_tick: None,
1226 });
1227 state.entities = vec![EntityState {
1228 id: 1,
1229 label: "You".into(),
1230 transform: Transform {
1231 position: WorldCoord::surface(128.0, 128.0),
1232 yaw: 0.0,
1233 velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
1234 },
1235 vitals: Some(PlayerVitals::default()),
1236 attributes: Some(PrimaryAttributes::default()),
1237 skills: Some(flatland_protocol::PlayerSkills::default()),
1238 inside_building: None,
1239 tile_id: None,
1240 paperdoll_ref: None,
1241 presentation_state: None,
1242 sprite_mode: None,
1243 progression_xp: None,
1244 combat_cues: Vec::new(),
1245 }];
1246 state.player = state.entities.first().cloned();
1247 let view = WorldView::build_with_target(&state, 9, 9, None);
1248 let flat = view.cells.join("");
1249 let water = map_presentation::shallow_water_presentation();
1250 assert!(
1251 flat.contains(&water.glyph),
1252 "expected water tiles ({:?}): {flat}",
1253 water.glyph
1254 );
1255 assert!(
1256 view.cell_fg.iter().any(|c| *c == Some(water.color)),
1257 "expected water color on terrain cells"
1258 );
1259 }
1260
1261 #[test]
1262 fn zone_glyph_and_color_overrides_paint_on_map() {
1263 use flatland_protocol::TerrainZoneView;
1264 let mut state = state_with_building(BuildingView {
1265 id: "x".into(),
1266 label: "X".into(),
1267 x: 128.0,
1268 y: 128.0,
1269 width_m: 1.0,
1270 depth_m: 1.0,
1271 interior_blueprint: None,
1272 tags: vec![],
1273 market_boundary_zone_ids: vec![],
1274 market_max_volume: None,
1275 wall_set: None,
1276 roof_set: None,
1277 });
1278 state.terrain_zones.push(TerrainZoneView {
1279 id: "marked".into(),
1280 x0: 126.0,
1281 y0: 126.0,
1282 x1: 130.0,
1283 y1: 130.0,
1284 kind: TerrainKindView::Grass,
1285 elevation: 0.0,
1286 glyph: Some("%".into()),
1287 color: Some("magenta".into()),
1288 tile_id: None,
1289 z_order: 0,
1290 channel_start_tick: None,
1291 channel_end_tick: None,
1292 });
1293 state.entities = vec![EntityState {
1294 id: 1,
1295 label: "You".into(),
1296 transform: Transform {
1297 position: WorldCoord::surface(128.0, 128.0),
1298 yaw: 0.0,
1299 velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
1300 },
1301 vitals: Some(PlayerVitals::default()),
1302 attributes: Some(PrimaryAttributes::default()),
1303 skills: Some(flatland_protocol::PlayerSkills::default()),
1304 inside_building: None,
1305 tile_id: None,
1306 paperdoll_ref: None,
1307 presentation_state: None,
1308 sprite_mode: None,
1309 progression_xp: None,
1310 combat_cues: Vec::new(),
1311 }];
1312 state.player = state.entities.first().cloned();
1313 let view = WorldView::build_with_target(&state, 9, 9, None);
1314 let flat = view.cells.join("");
1315 assert!(
1316 flat.contains('%'),
1317 "expected custom zone glyph on map: {flat}"
1318 );
1319 assert!(
1320 view.cell_fg.iter().any(|c| *c == Some(RgbColor::MAGENTA)),
1321 "expected custom zone color on terrain cells"
1322 );
1323 }
1324
1325 #[test]
1326 fn well_paints_water_ring_and_center() {
1327 let building = BuildingView {
1328 id: "town_well".into(),
1329 label: "Well".into(),
1330 x: 122.0,
1331 y: 106.0,
1332 width_m: 3.0,
1333 depth_m: 3.0,
1334 interior_blueprint: None,
1335 tags: vec!["well".into()],
1336 market_boundary_zone_ids: vec![],
1337 market_max_volume: None,
1338 wall_set: None,
1339 roof_set: None,
1340 };
1341 let mut state = state_with_building(building);
1342 state.entities = vec![EntityState {
1343 id: 1,
1344 label: "You".into(),
1345 transform: Transform {
1346 position: WorldCoord::surface(120.0, 106.0),
1347 yaw: 0.0,
1348 velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
1349 },
1350 vitals: Some(PlayerVitals::default()),
1351 attributes: Some(PrimaryAttributes::default()),
1352 skills: Some(flatland_protocol::PlayerSkills::default()),
1353 inside_building: None,
1354 tile_id: None,
1355 paperdoll_ref: None,
1356 presentation_state: None,
1357 sprite_mode: None,
1358 progression_xp: None,
1359 combat_cues: Vec::new(),
1360 }];
1361 state.player = state.entities.first().cloned();
1362 let view = WorldView::build_with_target(&state, 9, 9, None);
1363 let flat = view.cells.join("");
1364 assert!(flat.contains('~'), "expected well water: {flat}");
1365 assert!(flat.contains('O'), "expected well center: {flat}");
1366 }
1367
1368 #[test]
1369 fn broker_hut_draws_wall_outline() {
1370 let building = BuildingView {
1371 id: "broker_hut".into(),
1372 label: "Broker's Hut".into(),
1373 x: 148.0,
1374 y: 118.0,
1375 width_m: 8.0,
1376 depth_m: 6.0,
1377 interior_blueprint: None,
1378 tags: vec![],
1379 market_boundary_zone_ids: vec![],
1380 market_max_volume: None,
1381 wall_set: None,
1382 roof_set: None,
1383 };
1384 let mut state = state_with_building(building);
1385 state.player = state.entities.first().cloned();
1386 let view = WorldView::build_with_target(&state, 25, 15, None);
1387 let flat = view.cells.join("");
1388 assert!(flat.contains('+'), "expected corners: {flat}");
1389 assert!(flat.contains('-'), "expected horiz walls: {flat}");
1390 assert!(flat.contains('|'), "expected vert walls: {flat}");
1391 }
1392
1393 #[test]
1394 fn interior_map_renders_rooms_and_walls() {
1395 use flatland_protocol::{InteriorMapView, InteriorRoomView};
1396 let building = BuildingView {
1397 id: "broker_hut".into(),
1398 label: "Broker's Hut".into(),
1399 x: 148.0,
1400 y: 118.0,
1401 width_m: 8.0,
1402 depth_m: 6.0,
1403 interior_blueprint: Some("broker_hut".into()),
1404 tags: vec![],
1405 market_boundary_zone_ids: vec![],
1406 market_max_volume: None,
1407 wall_set: None,
1408 roof_set: None,
1409 };
1410 let mut state = state_with_building(building);
1411 state.interior_map = Some(InteriorMapView {
1412 building_id: "broker_hut".into(),
1413 blueprint_id: "broker_hut".into(),
1414 background_color: "#000000".into(),
1415 default_floor_color: Some("#2a2a2a".into()),
1416 floor_height_m: 3.0,
1417 z_platforms: vec![],
1418 z_transitions: vec![],
1419 rooms: vec![InteriorRoomView {
1420 id: "main".into(),
1421 label: "Main".into(),
1422 floor: 0,
1423 x0: 0.0,
1424 y0: 0.0,
1425 x1: 8.5,
1426 y1: 7.0,
1427 floor_color: None,
1428 floor_glyph: None,
1429 }],
1430 room_doors: vec![],
1431 });
1432 state.entities = vec![EntityState {
1433 id: 1,
1434 label: "You".into(),
1435 transform: Transform {
1436 position: WorldCoord::surface(4.0, 3.0),
1437 yaw: 0.0,
1438 velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
1439 },
1440 vitals: Some(PlayerVitals::default()),
1441 attributes: Some(PrimaryAttributes::default()),
1442 skills: Some(flatland_protocol::PlayerSkills::default()),
1443 inside_building: Some("broker_hut".into()),
1444 tile_id: None,
1445 paperdoll_ref: None,
1446 presentation_state: None,
1447 sprite_mode: None,
1448 progression_xp: None,
1449 combat_cues: Vec::new(),
1450 }];
1451 state.player = state.entities.first().cloned();
1452 let view = WorldView::build_with_target(&state, 25, 15, None);
1453 assert_eq!(view.inside_building.as_deref(), Some("broker_hut"));
1454 let flat = view.cells.join("");
1455 assert!(flat.contains('+'), "expected interior walls: {flat}");
1456 assert!(flat.contains('@'), "expected player marker: {flat}");
1457 }
1458
1459 #[test]
1460 fn stale_interior_map_renders_outdoor_when_outside() {
1461 use flatland_protocol::{InteriorMapView, InteriorRoomView};
1462 let building = BuildingView {
1463 id: "town_hall".into(),
1464 label: "Town Hall".into(),
1465 x: 163.0,
1466 y: 137.0,
1467 width_m: 20.0,
1468 depth_m: 10.0,
1469 interior_blueprint: Some("town_hall".into()),
1470 tags: vec![],
1471 market_boundary_zone_ids: vec![],
1472 market_max_volume: None,
1473 wall_set: None,
1474 roof_set: None,
1475 };
1476 let mut state = state_with_building(building);
1477 state.interior_map = Some(InteriorMapView {
1478 building_id: "town_hall".into(),
1479 blueprint_id: "town_hall".into(),
1480 background_color: "#000000".into(),
1481 default_floor_color: Some("#2a2a2a".into()),
1482 floor_height_m: 3.0,
1483 z_platforms: vec![],
1484 z_transitions: vec![],
1485 rooms: vec![InteriorRoomView {
1486 id: "main_hall".into(),
1487 label: "Main".into(),
1488 floor: 0,
1489 x0: -3.5,
1490 y0: -8.0,
1491 x1: 18.5,
1492 y1: 6.0,
1493 floor_color: None,
1494 floor_glyph: None,
1495 }],
1496 room_doors: vec![],
1497 });
1498 state.player = state.entities.first().cloned();
1499 let view = WorldView::build_with_target(&state, 25, 15, None);
1500 assert!(view.inside_building.is_none());
1501 let flat = view.cells.join("");
1502 let grass = map_presentation::terrain_for(TerrainKindView::Grass).glyph;
1503 assert!(
1504 flat.contains(&grass),
1505 "expected outdoor terrain, not stale interior background: {flat}"
1506 );
1507 assert!(
1508 !flat.chars().all(|c| c == ' ' || c == '@'),
1509 "stale interior_map must not paint black interior when outside"
1510 );
1511 }
1512
1513 #[test]
1514 fn stale_inside_flag_still_renders_outdoor_world() {
1515 use flatland_protocol::ResourceNodeState;
1516 let building = BuildingView {
1517 id: "broker_hut".into(),
1518 label: "Broker's Hut".into(),
1519 x: 148.0,
1520 y: 118.0,
1521 width_m: 8.0,
1522 depth_m: 6.0,
1523 interior_blueprint: None,
1524 tags: vec![],
1525 market_boundary_zone_ids: vec![],
1526 market_max_volume: None,
1527 wall_set: None,
1528 roof_set: None,
1529 };
1530 let mut state = state_with_building(building);
1531 state.entities = vec![EntityState {
1532 id: 1,
1533 label: "You".into(),
1534 transform: Transform {
1535 position: WorldCoord::surface(128.0, 128.0),
1536 yaw: 0.0,
1537 velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
1538 },
1539 vitals: Some(PlayerVitals::default()),
1540 attributes: Some(PrimaryAttributes::default()),
1541 skills: Some(flatland_protocol::PlayerSkills::default()),
1542 inside_building: Some("broker_hut".into()),
1543 tile_id: None,
1544 paperdoll_ref: None,
1545 presentation_state: None,
1546 sprite_mode: None,
1547 progression_xp: None,
1548 combat_cues: Vec::new(),
1549 }];
1550 state.player = state.entities.first().cloned();
1551 state
1552 .resource_nodes
1553 .push(flatland_protocol::ResourceNodeView {
1554 id: "oak".into(),
1555 label: "Oak".into(),
1556 x: 126.0,
1557 y: 134.0,
1558 z: 0.0,
1559 item_template: "oak_log".into(),
1560 state: ResourceNodeState::Available,
1561 blocking: true,
1562 blocking_radius_m: 0.8,
1563 tile_id: None,
1564 yaw: 0.0,
1565 pitch: 0.0,
1566 roll: 0.0,
1567 draw_scale: 1.0,
1568 sprite_mode: None,
1569 growth_progress: None,
1570 presentation_state: None,
1571 channel_start_tick: None,
1572 channel_end_tick: None,
1573 harvest_drop_templates: Vec::new(),});
1574 let view = WorldView::build_with_target(&state, 25, 15, None);
1575 assert_eq!(
1576 view.inside_building.as_deref(),
1577 Some("broker_hut"),
1578 "server inside flag is authoritative"
1579 );
1580 let flat = view.cells.join("");
1581 let oak = map_presentation::resource_for(&flatland_protocol::ResourceNodeView {
1582 id: "oak".into(),
1583 label: "Oak".into(),
1584 x: 0.0,
1585 y: 0.0,
1586 z: 0.0,
1587 item_template: "oak_log".into(),
1588 state: flatland_protocol::ResourceNodeState::Available,
1589 blocking: true,
1590 blocking_radius_m: 0.8,
1591 tile_id: None,
1592 yaw: 0.0,
1593 pitch: 0.0,
1594 roll: 0.0,
1595 draw_scale: 1.0,
1596 sprite_mode: None,
1597 growth_progress: None,
1598 presentation_state: None,
1599 channel_start_tick: None,
1600 channel_end_tick: None,
1601 harvest_drop_templates: Vec::new(),});
1602 assert!(
1603 flat.contains(&oak.glyph),
1604 "expected nearby tree ({:?}): {flat}",
1605 oak.glyph
1606 );
1607 assert!(flat.contains('@'), "expected player: {flat}");
1608 }
1609
1610 #[test]
1611 fn inside_building_flag_selects_active_instance() {
1612 let town = BuildingView {
1613 id: "town_hall".into(),
1614 label: "Town Hall".into(),
1615 x: 160.0,
1616 y: 136.0,
1617 width_m: 20.0,
1618 depth_m: 10.0,
1619 interior_blueprint: Some("town_hall".into()),
1620 tags: vec![],
1621 market_boundary_zone_ids: vec![],
1622 market_max_volume: None,
1623 wall_set: None,
1624 roof_set: None,
1625 };
1626 let guild = BuildingView {
1627 id: "guild_hall".into(),
1628 label: "Guild Hall".into(),
1629 x: 164.0,
1630 y: 152.0,
1631 width_m: 20.0,
1632 depth_m: 14.0,
1633 interior_blueprint: Some("guild_hall".into()),
1634 tags: vec![],
1635 market_boundary_zone_ids: vec![],
1636 market_max_volume: None,
1637 wall_set: None,
1638 roof_set: None,
1639 };
1640 let mut state = state_with_building(town);
1641 state.buildings.push(guild);
1642 state.entities = vec![EntityState {
1643 id: 1,
1644 label: "You".into(),
1645 transform: Transform {
1646 position: WorldCoord::surface(4.0, 3.0),
1647 yaw: 0.0,
1648 velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
1649 },
1650 vitals: Some(PlayerVitals::default()),
1651 attributes: Some(PrimaryAttributes::default()),
1652 skills: Some(flatland_protocol::PlayerSkills::default()),
1653 inside_building: Some("guild_hall".into()),
1654 tile_id: None,
1655 paperdoll_ref: None,
1656 presentation_state: None,
1657 sprite_mode: None,
1658 progression_xp: None,
1659 combat_cues: Vec::new(),
1660 }];
1661 state.player = state.entities.first().cloned();
1662 state.world_width_m = 256.0;
1663 state.world_height_m = 256.0;
1664
1665 let view = WorldView::build_with_target(&state, 25, 15, None);
1666 assert_eq!(view.inside_building.as_deref(), Some("guild_hall"));
1667 }
1668
1669 #[test]
1670 fn combat_target_marks_creature_cell() {
1671 let building = BuildingView {
1672 id: "x".into(),
1673 label: "X".into(),
1674 x: 128.0,
1675 y: 128.0,
1676 width_m: 1.0,
1677 depth_m: 1.0,
1678 interior_blueprint: None,
1679 tags: vec![],
1680 market_boundary_zone_ids: vec![],
1681 market_max_volume: None,
1682 wall_set: None,
1683 roof_set: None,
1684 };
1685 let mut state = state_with_building(building);
1686 state.entities = vec![
1687 EntityState {
1688 id: 1,
1689 label: "You".into(),
1690 transform: Transform {
1691 position: WorldCoord::surface(100.0, 100.0),
1692 yaw: 0.0,
1693 velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
1694 },
1695 vitals: Some(PlayerVitals::default()),
1696 attributes: Some(PrimaryAttributes::default()),
1697 skills: Some(flatland_protocol::PlayerSkills::default()),
1698 inside_building: None,
1699 tile_id: None,
1700 paperdoll_ref: None,
1701 presentation_state: None,
1702 sprite_mode: None,
1703 progression_xp: None,
1704 combat_cues: Vec::new(),
1705 },
1706 EntityState {
1707 id: 42,
1708 label: "Rabbit".into(),
1709 transform: Transform {
1710 position: WorldCoord::surface(103.0, 100.0),
1711 yaw: 0.0,
1712 velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
1713 },
1714 vitals: None,
1715 attributes: None,
1716 skills: None,
1717 inside_building: None,
1718 tile_id: None,
1719 paperdoll_ref: None,
1720 presentation_state: None,
1721 sprite_mode: None,
1722 progression_xp: None,
1723 combat_cues: Vec::new(),
1724 },
1725 ];
1726 state.player = state.entities.first().cloned();
1727 state.combat_target = Some(42);
1728 state.combat_target_label = Some("Rabbit".into());
1729
1730 let view = WorldView::build_with_target(&state, 25, 15, None);
1731 let marked: usize = view
1732 .target_t1_cells
1733 .iter()
1734 .chain(view.target_t2_cells.iter())
1735 .filter(|b| **b)
1736 .count();
1737 assert_eq!(marked, 1, "exactly one targeted cell");
1738 let idx = view
1739 .target_t1_cells
1740 .iter()
1741 .chain(view.target_t2_cells.iter())
1742 .position(|b| *b)
1743 .expect("target cell");
1744 assert_eq!(view.cells[idx], "R");
1745 }
1746
1747 #[test]
1748 fn combat_target_ring_prefers_live_npc_coords() {
1749 let 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 market_boundary_zone_ids: vec![],
1759 market_max_volume: None,
1760 wall_set: None,
1761 roof_set: None,
1762 };
1763 let mut state = state_with_building(building);
1764 state.entities = vec![
1765 EntityState {
1766 id: 1,
1767 label: "You".into(),
1768 transform: Transform {
1769 position: WorldCoord::surface(100.0, 100.0),
1770 yaw: 0.0,
1771 velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
1772 },
1773 vitals: Some(PlayerVitals::default()),
1774 attributes: Some(PrimaryAttributes::default()),
1775 skills: Some(flatland_protocol::PlayerSkills::default()),
1776 inside_building: None,
1777 tile_id: None,
1778 paperdoll_ref: None,
1779 presentation_state: None,
1780 sprite_mode: None,
1781 progression_xp: None,
1782 combat_cues: Vec::new(),
1783 },
1784 EntityState {
1785 id: 42,
1786 label: "Rabbit".into(),
1787 transform: Transform {
1789 position: WorldCoord::surface(90.0, 100.0),
1790 yaw: 0.0,
1791 velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
1792 },
1793 vitals: None,
1794 attributes: None,
1795 skills: None,
1796 inside_building: None,
1797 tile_id: None,
1798 paperdoll_ref: None,
1799 presentation_state: None,
1800 sprite_mode: None,
1801 progression_xp: None,
1802 combat_cues: Vec::new(),
1803 },
1804 ];
1805 state.npcs = vec![flatland_protocol::NpcView {
1806 id: "rabbit-1".into(),
1807 label: "Rabbit".into(),
1808 role: "wildlife".into(),
1809 x: 103.0,
1810 y: 100.0,
1811 building_id: None,
1812 entity_id: Some(42),
1813 life_state: Some(flatland_protocol::LifeState::Alive),
1814 hp_pct: Some(1.0),
1815 can_trade: false,
1816 tile_id: None,
1817 behavior_state: None,
1818 presentation_state: None,
1819 sprite_mode: None,
1820 paperdoll_ref: None,
1821 }];
1822 state.player = state.entities.first().cloned();
1823 state.combat_target = Some(42);
1824
1825 let view = WorldView::build_with_target(&state, 25, 15, None);
1826 let marked = view
1827 .target_t1_cells
1828 .iter()
1829 .position(|b| *b)
1830 .expect("target cell");
1831 let half_w = (view.width / 2) as i32;
1832 let half_h = (view.height / 2) as i32;
1833 let (live_gx, live_gy) =
1834 world_to_grid(103.0, 100.0, 100.0, 100.0, half_w, half_h, view.width, view.height)
1835 .expect("live npc in view");
1836 let (stale_gx, stale_gy) =
1837 world_to_grid(90.0, 100.0, 100.0, 100.0, half_w, half_h, view.width, view.height)
1838 .expect("stale entity in view");
1839 let live_idx = live_gy * view.width + live_gx;
1840 let stale_idx = stale_gy * view.width + stale_gx;
1841 assert_eq!(marked, live_idx, "ring must follow live NPC coords");
1842 assert_ne!(marked, stale_idx, "ring must not stay on stale entity coords");
1843 }
1844
1845 #[test]
1846 fn grid_to_world_roundtrips_center() {
1847 let px = 10.0;
1848 let py = 20.0;
1849 let view_w = 11;
1850 let view_h = 11;
1851 let half_w = (view_w / 2) as i32;
1852 let half_h = (view_h / 2) as i32;
1853 let (gx, gy) =
1854 world_to_grid(12.0, 18.0, px, py, half_w, half_h, view_w, view_h).expect("in view");
1855 let (wx, wy) = grid_to_world(gx, gy, px, py, view_w, view_h).expect("inverse");
1856 assert!((wx - 12.0).abs() < 0.01);
1857 assert!((wy - 18.0).abs() < 0.01);
1858 }
1859
1860 #[test]
1861 fn vertical_axis_quantizes_to_one_meter() {
1862 let px = 0.0;
1863 let py = 0.0;
1864 let view_w = 21;
1865 let view_h = 21;
1866 let half_w = (view_w / 2) as i32;
1867 let half_h = (view_h / 2) as i32;
1868 let (gx, gy) =
1869 world_to_grid(3.0, 3.0, px, py, half_w, half_h, view_w, view_h).expect("in view");
1870 let (wx, wy) = grid_to_world(gx, gy, px, py, view_w, view_h).expect("inverse");
1871 assert!(
1872 (wx - 3.0).abs() < 0.01,
1873 "x should stay exact to 1m: got {wx}"
1874 );
1875 assert!(
1876 (wy - 3.0).abs() < 0.01,
1877 "y should stay exact to 1m: got {wy}"
1878 );
1879 }
1880
1881 #[test]
1882 fn square_extent_spans_equal_rows_and_columns() {
1883 let px = 0.0;
1884 let py = 0.0;
1885 let half_w = 50;
1886 let half_h = 50;
1887 let width = 101;
1888 let height = 101;
1889 let (gx0, gy0) =
1890 world_to_grid(-4.0, -4.0, px, py, half_w, half_h, width, height).expect("in view");
1891 let (gx1, gy1) =
1892 world_to_grid(4.0, 4.0, px, py, half_w, half_h, width, height).expect("in view");
1893 let cols_spanned = (gx1 as i32 - gx0 as i32).unsigned_abs();
1894 let rows_spanned = (gy1 as i32 - gy0 as i32).unsigned_abs();
1895 assert_eq!(cols_spanned, 8, "8m wide should span 8 columns");
1896 assert_eq!(rows_spanned, 8, "8m tall should span 8 rows");
1897 }
1898
1899 #[test]
1900 fn resource_paints_on_terrain_cell_for_same_world_coords() {
1901 use flatland_protocol::{ResourceNodeState, ResourceNodeView, TerrainZoneView};
1902
1903 let px = 128.0;
1904 let py = 128.0;
1905 let rx = 131.0;
1906 let ry = 132.0;
1907 let mut state = state_with_building(BuildingView {
1908 id: "x".into(),
1909 label: "X".into(),
1910 x: 128.0,
1911 y: 128.0,
1912 width_m: 1.0,
1913 depth_m: 1.0,
1914 interior_blueprint: None,
1915 tags: vec![],
1916 market_boundary_zone_ids: vec![],
1917 market_max_volume: None,
1918 wall_set: None,
1919 roof_set: None,
1920 });
1921 state.terrain_zones.push(TerrainZoneView {
1922 id: "pond".into(),
1923 x0: rx,
1924 y0: ry,
1925 x1: rx + 1.0,
1926 y1: ry + 1.0,
1927 kind: TerrainKindView::ShallowWater,
1928 elevation: -0.5,
1929 glyph: None,
1930 color: None,
1931 tile_id: None,
1932 z_order: 0,
1933 channel_start_tick: None,
1934 channel_end_tick: None,
1935 });
1936 state.resource_nodes.push(ResourceNodeView {
1937 id: "oak".into(),
1938 label: "Oak".into(),
1939 x: rx,
1940 y: ry,
1941 z: 0.0,
1942 item_template: "oak_log".into(),
1943 state: ResourceNodeState::Available,
1944 blocking: true,
1945 blocking_radius_m: 0.8,
1946 tile_id: None,
1947 yaw: 0.0,
1948 pitch: 0.0,
1949 roll: 0.0,
1950 draw_scale: 1.0,
1951 sprite_mode: None,
1952 growth_progress: None,
1953 presentation_state: None,
1954 channel_start_tick: None,
1955 channel_end_tick: None,
1956 harvest_drop_templates: Vec::new(),});
1957 state.entities[0].transform.position = WorldCoord::surface(px, py);
1958 state.player = state.entities.first().cloned();
1959
1960 let view = WorldView::build_with_target(&state, 25, 15, None);
1961 let half_w = (view.width / 2) as i32;
1962 let half_h = (view.height / 2) as i32;
1963 let (gx, gy) = world_to_grid(rx, ry, px, py, half_w, half_h, view.width, view.height)
1964 .expect("resource in view");
1965 let idx = gy * view.width + gx;
1966 let oak = map_presentation::resource_for(&state.resource_nodes[0]);
1967 assert_eq!(
1968 view.cells[idx], oak.glyph,
1969 "resource should paint on the grid cell for its world coords"
1970 );
1971 let (wx, wy) = grid_to_world(gx, gy, px, py, view.width, view.height).expect("inverse");
1972 assert!(
1973 (wx - rx).abs() < 0.01 && (wy - ry).abs() < 0.01,
1974 "resource grid cell should sample terrain at ({wx}, {wy}), expected ({rx}, {ry})"
1975 );
1976 }
1977
1978 #[test]
1979 fn chest_and_loot_paint_on_non_grass_terrain() {
1980 use flatland_protocol::{GroundDropView, PlacedContainerView, TerrainZoneView};
1981
1982 let px = 50.0;
1983 let py = 50.0;
1984 let cx = 53.0;
1985 let cy = 52.0;
1986 let lx = 54.0;
1987 let ly = 52.0;
1988 let mut state = state_with_building(BuildingView {
1989 id: "x".into(),
1990 label: "X".into(),
1991 x: 50.0,
1992 y: 50.0,
1993 width_m: 1.0,
1994 depth_m: 1.0,
1995 interior_blueprint: None,
1996 tags: vec![],
1997 market_boundary_zone_ids: vec![],
1998 market_max_volume: None,
1999 wall_set: None,
2000 roof_set: None,
2001 });
2002 state.terrain_zones.push(TerrainZoneView {
2003 id: "trail".into(),
2004 x0: 52.0,
2005 y0: 51.0,
2006 x1: 56.0,
2007 y1: 54.0,
2008 kind: TerrainKindView::Trail,
2009 elevation: 0.0,
2010 glyph: None,
2011 color: None,
2012 tile_id: None,
2013 z_order: 0,
2014 channel_start_tick: None,
2015 channel_end_tick: None,
2016 });
2017 state.placed_containers.push(PlacedContainerView {
2018 id: "chest_1".into(),
2019 template_id: "wood_chest".into(),
2020 display_name: "Storage".into(),
2021 x: cx,
2022 y: cy,
2023 z: 0.0,
2024 locked: false,
2025 accessible: true,
2026 owner_character_id: None,
2027 contents: vec![],
2028 lock_id: None,
2029 capacity_volume: Some(40.0),
2030 item_instance_id: None,
2031 tile_id: None,
2032 worker_lodging_capacity: None,
2033 blocking: true,
2034 blocking_radius_m: 0.8,});
2035 state.ground_drops.push(GroundDropView {
2036 id: "drop_1".into(),
2037 template_id: "lumber".into(),
2038 quantity: 2,
2039 x: lx,
2040 y: ly,
2041 z: 0.0,
2042 tile_id: None,
2043 display_name: None,
2044 yaw: 0.0,
2045 pitch: 0.0,
2046 roll: 0.0,
2047 draw_scale: 1.0,
2048 });
2049 state.entities[0].transform.position = WorldCoord::surface(px, py);
2050 state.player = state.entities.first().cloned();
2051
2052 let view = WorldView::build_with_target(&state, 25, 15, None);
2053 let half_w = (view.width / 2) as i32;
2054 let half_h = (view.height / 2) as i32;
2055 let (gx, gy) =
2056 world_to_grid(cx, cy, px, py, half_w, half_h, view.width, view.height).expect("chest");
2057 let chest = map_presentation::chest_presentation(false);
2058 assert_eq!(
2059 view.cells[gy * view.width + gx],
2060 chest.glyph,
2061 "chest must paint on trail/non-grass cells"
2062 );
2063 let (gx2, gy2) =
2064 world_to_grid(lx, ly, px, py, half_w, half_h, view.width, view.height).expect("loot");
2065 let loot = map_presentation::loot_presentation();
2066 assert_eq!(
2067 view.cells[gy2 * view.width + gx2],
2068 loot.glyph,
2069 "ground loot must paint on trail/non-grass cells"
2070 );
2071 }
2072
2073 #[test]
2074 fn build_with_anchor_sets_view_origin() {
2075 let mut state = state_with_building(BuildingView {
2076 id: "x".into(),
2077 label: "X".into(),
2078 x: 128.0,
2079 y: 128.0,
2080 width_m: 1.0,
2081 depth_m: 1.0,
2082 interior_blueprint: None,
2083 tags: vec![],
2084 market_boundary_zone_ids: vec![],
2085 market_max_volume: None,
2086 wall_set: None,
2087 roof_set: None,
2088 });
2089 state.entities[0].transform.position = WorldCoord::surface(100.0, 200.0);
2090 state.player = state.entities.first().cloned();
2091 let view = WorldView::build_with_anchor(
2092 &state,
2093 21,
2094 15,
2095 12.3,
2096 40.7,
2097 None,
2098 WorldViewOptions::terrain_only(),
2099 );
2100 assert!((view.origin_x - 12.3).abs() < 0.01);
2101 assert!((view.origin_y - 40.7).abs() < 0.01);
2102 }
2103
2104 #[test]
2105 fn skip_local_player_glyph_when_paint_local_player_false() {
2106 let mut state = state_with_building(BuildingView {
2107 id: "x".into(),
2108 label: "X".into(),
2109 x: 10.0,
2110 y: 10.0,
2111 width_m: 1.0,
2112 depth_m: 1.0,
2113 interior_blueprint: None,
2114 tags: vec![],
2115 market_boundary_zone_ids: vec![],
2116 market_max_volume: None,
2117 wall_set: None,
2118 roof_set: None,
2119 });
2120 state.entities[0].transform.position = WorldCoord::surface(128.0, 128.0);
2121 state.player = state.entities.first().cloned();
2122 let player_glyph = map_presentation::player_presentation().glyph;
2123
2124 let with_player =
2125 WorldView::build_with_options(&state, 21, 15, None, WorldViewOptions::default());
2126 let cx = with_player.width / 2;
2127 let cy = with_player.height / 2;
2128 assert_eq!(
2129 with_player.cells[cy * with_player.width + cx],
2130 player_glyph,
2131 "default build paints @"
2132 );
2133
2134 let without =
2135 WorldView::build_with_options(&state, 21, 15, None, WorldViewOptions::terrain_only());
2136 assert_ne!(
2137 without.cells[cy * without.width + cx],
2138 player_glyph,
2139 "gfx sprite mode must not paint local player @"
2140 );
2141 }
2142
2143 fn town_hall_state(px: f32, py: f32) -> GameState {
2144 use flatland_protocol::{InteriorMapView, InteriorRoomView};
2145 let mut state = state_with_building(BuildingView {
2146 id: "town_hall".into(),
2147 label: "Town Hall".into(),
2148 x: 156.0,
2149 y: 153.0,
2150 width_m: 20.0,
2151 depth_m: 9.0,
2152 interior_blueprint: Some("town_hall".into()),
2153 tags: vec![],
2154 market_boundary_zone_ids: vec![],
2155 market_max_volume: None,
2156 wall_set: None,
2157 roof_set: None,
2158 });
2159 state.interior_map = Some(InteriorMapView {
2160 building_id: "town_hall".into(),
2161 blueprint_id: "town_hall".into(),
2162 background_color: "#000".into(),
2163 default_floor_color: Some("#2a2a2a".into()),
2164 floor_height_m: 3.0,
2165 z_platforms: vec![],
2166 z_transitions: vec![],
2167 rooms: vec![
2168 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 },
2169 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 },
2170 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 },
2171 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 },
2172 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 },
2173 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 },
2174 ],
2175 room_doors: vec![],
2176 });
2177 state.entities[0].transform.position = WorldCoord::surface(px, py);
2178 state.entities[0].inside_building = Some("town_hall".into());
2179 state.player = state.entities.first().cloned();
2180 state
2181 }
2182
2183 #[test]
2186 fn outer_perimeter_walls_render_at_positive_anchor() {
2187 let state = town_hall_state(20.0, 6.0);
2188 let view = WorldView::build_with_anchor(
2189 &state,
2190 31,
2191 31,
2192 20.0,
2193 6.0,
2194 None,
2195 WorldViewOptions::terrain_only(),
2196 );
2197 let cell = |x: i32, y: i32| {
2198 let (gx, gy) =
2199 world_to_grid(x as f32, y as f32, 20.0, 6.0, 15, 15, view.width, view.height)
2200 .expect("in view");
2201 view.cells[gy * view.width + gx].clone()
2202 };
2203 assert!(is_wall(&cell(26, 9)), "weapon east wall at (26,9): {}", cell(26, 9));
2206 assert!(
2207 cell(26, 13) == "+" || cell(26, 13) == "-" || cell(26, 13) == "|",
2208 "top wall east corner (26,13): {}",
2209 cell(26, 13)
2210 );
2211 assert!(is_wall(&cell(19, 9)), "office east wall at (19,9): {}", cell(19, 9));
2213 assert!(is_wall(&cell(24, -4)), "kitchen east wall at (24,-4): {}", cell(24, -4));
2215 assert!(
2217 cell(24, -8) == "+" || cell(24, -8) == "-" || cell(24, -8) == "|",
2218 "bottom wall kitchen-east corner (24,-8): {}",
2219 cell(24, -8)
2220 );
2221 assert!(
2223 cell(12, 13) == "+" || cell(12, 13) == "-" || cell(12, 13) == "|",
2224 "top wall at (12,13): {}",
2225 cell(12, 13)
2226 );
2227 }
2228}