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