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