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