1use flatland_client_lib::GameState;
2use flatland_protocol::{BuildingView, InteriorMapView, TerrainKindView};
3use crate::color::RgbColor;
4
5use crate::map_presentation::{self, MapPresentation};
6
7pub struct WorldView {
12 pub width: usize,
13 pub height: usize,
14 pub cells: Vec<String>,
15 pub cell_fg: Vec<Option<RgbColor>>,
17 pub target_t1_cells: Vec<bool>,
19 pub target_t2_cells: Vec<bool>,
21 pub origin_x: f32,
22 pub origin_y: f32,
23 pub inside_building: Option<String>,
24}
25
26#[derive(Debug, Clone, Copy)]
28pub struct WorldViewOptions {
29 pub paint_local_player: bool,
31 pub paint_overlays: bool,
34}
35
36impl Default for WorldViewOptions {
37 fn default() -> Self {
38 Self {
39 paint_local_player: true,
40 paint_overlays: true,
41 }
42 }
43}
44
45impl WorldViewOptions {
46 pub fn terrain_only() -> Self {
48 Self {
49 paint_local_player: false,
50 paint_overlays: false,
51 }
52 }
53}
54
55impl WorldView {
56 pub fn build_with_target(
57 state: &GameState,
58 view_w: usize,
59 view_h: usize,
60 map_target: Option<(f32, f32)>,
61 ) -> Self {
62 Self::build_with_options(
63 state,
64 view_w,
65 view_h,
66 map_target,
67 WorldViewOptions::default(),
68 )
69 }
70
71 pub fn build_with_options(
73 state: &GameState,
74 view_w: usize,
75 view_h: usize,
76 map_target: Option<(f32, f32)>,
77 options: WorldViewOptions,
78 ) -> Self {
79 let (px, py) = state.player_position();
80 Self::build_with_anchor(state, view_w, view_h, px, py, map_target, options)
81 }
82
83 pub fn build_with_anchor(
85 state: &GameState,
86 view_w: usize,
87 view_h: usize,
88 anchor_x: f32,
89 anchor_y: f32,
90 map_target: Option<(f32, f32)>,
91 options: WorldViewOptions,
92 ) -> Self {
93 map_presentation::maybe_reload_for_content_rev(state.content_rev);
94 let px = anchor_x;
95 let py = anchor_y;
96 let inside_building = state.effective_inside_building();
97
98 let width = view_w.max(3);
99 let height = view_h.max(3);
100 let grass = map_presentation::terrain_for(TerrainKindView::Grass);
101 let mut cells = vec![grass.glyph.clone(); width * height];
102 let mut cell_fg = vec![Some(grass.color); width * height];
103
104 let half_w = (width / 2) as i32;
105 let half_h = (height / 2) as i32;
106
107 if let (Some(_bid), Some(interior)) = (
109 inside_building.as_deref(),
110 state.interior_map.as_ref(),
111 ) {
112 paint_interior_background(&mut cells, &mut cell_fg, width, height, interior);
113 paint_interior_rooms(
114 &mut cells,
115 &mut cell_fg,
116 width,
117 height,
118 interior,
119 px,
120 py,
121 half_w,
122 half_h,
123 );
124 let door_gaps = interior_door_gaps(interior, &state.doors);
125 for room in &interior.rooms {
126 paint_building_walls(
127 &mut cells,
128 &mut cell_fg,
129 width,
130 height,
131 room.x0,
132 room.y0,
133 room.x1 - room.x0,
134 room.y1 - room.y0,
135 px,
136 py,
137 half_w,
138 half_h,
139 &door_gaps,
140 );
141 }
142 } else {
143 paint_terrain(
144 &mut cells,
145 &mut cell_fg,
146 width,
147 height,
148 state,
149 px,
150 py,
151 half_w,
152 half_h,
153 );
154
155 for building in &state.buildings {
156 if building.tags.iter().any(|t| t == "well") {
157 paint_well(
158 &mut cells,
159 &mut cell_fg,
160 width,
161 height,
162 building,
163 px,
164 py,
165 half_w,
166 half_h,
167 );
168 } else {
169 paint_building_walls_centered(
170 &mut cells,
171 &mut cell_fg,
172 width,
173 height,
174 building,
175 px,
176 py,
177 half_w,
178 half_h,
179 );
180 }
181 }
182 }
183
184 if options.paint_overlays {
185 for door in &state.doors {
186 if let Some((gx, gy)) = world_to_grid(
187 door.x,
188 door.y,
189 px,
190 py,
191 half_w,
192 half_h,
193 width,
194 height,
195 ) {
196 let idx = gy * width + gx;
197 paint_presentation(
198 &mut cells,
199 &mut cell_fg,
200 idx,
201 &map_presentation::door_presentation(door.open),
202 );
203 }
204 }
205
206 for npc in &state.npcs {
207 if let Some((gx, gy)) = world_to_grid(
208 npc.x,
209 npc.y,
210 px,
211 py,
212 half_w,
213 half_h,
214 width,
215 height,
216 ) {
217 let idx = gy * width + gx;
218 paint_presentation(
219 &mut cells,
220 &mut cell_fg,
221 idx,
222 &map_presentation::npc_for(npc),
223 );
224 }
225 }
226
227 let ground = empty_ground_glyph();
228 let player_glyph = map_presentation::player_presentation().glyph;
229 for node in &state.resource_nodes {
230 if let Some((gx, gy)) = world_to_grid(
231 node.x,
232 node.y,
233 px,
234 py,
235 half_w,
236 half_h,
237 width,
238 height,
239 ) {
240 let idx = gy * width + gx;
241 let pres = map_presentation::resource_for(node);
242 if can_paint_world_object(&cells[idx], &ground, &player_glyph) {
243 paint_presentation(&mut cells, &mut cell_fg, idx, &pres);
244 }
245 }
246 }
247
248 for drop in &state.ground_drops {
249 if let Some((gx, gy)) = world_to_grid(
250 drop.x,
251 drop.y,
252 px,
253 py,
254 half_w,
255 half_h,
256 width,
257 height,
258 ) {
259 let idx = gy * width + gx;
260 if can_paint_world_object(&cells[idx], &ground, &player_glyph) {
261 paint_presentation(
262 &mut cells,
263 &mut cell_fg,
264 idx,
265 &map_presentation::loot_presentation(),
266 );
267 }
268 }
269 }
270
271 for chest in &state.placed_containers {
272 if let Some((gx, gy)) = world_to_grid(
273 chest.x,
274 chest.y,
275 px,
276 py,
277 half_w,
278 half_h,
279 width,
280 height,
281 ) {
282 let idx = gy * width + gx;
283 if can_paint_world_object(&cells[idx], &ground, &player_glyph) {
284 paint_presentation(
285 &mut cells,
286 &mut cell_fg,
287 idx,
288 &map_presentation::chest_presentation(chest.locked),
289 );
290 }
291 }
292 }
293
294 if state.effective_inside_building().is_none() {
295 for inter in &state.interactables {
296 if inter.kind != "quest_board" {
297 continue;
298 }
299 if let Some((gx, gy)) = world_to_grid(
300 inter.x,
301 inter.y,
302 px,
303 py,
304 half_w,
305 half_h,
306 width,
307 height,
308 ) {
309 let idx = gy * width + gx;
310 if can_paint_world_object(&cells[idx], &ground, &player_glyph) {
311 paint_presentation(
312 &mut cells,
313 &mut cell_fg,
314 idx,
315 &map_presentation::quest_board_presentation(),
316 );
317 }
318 }
319 }
320 }
321
322 for entity in &state.entities {
323 if !options.paint_local_player && entity.id == state.entity_id {
325 continue;
326 }
327 if let Some(pres) = entity_presentation(entity, state.entity_id) {
328 if let Some((gx, gy)) = world_to_grid(
329 entity.transform.position.x,
330 entity.transform.position.y,
331 px,
332 py,
333 half_w,
334 half_h,
335 width,
336 height,
337 ) {
338 let idx = gy * width + gx;
339 paint_presentation(&mut cells, &mut cell_fg, idx, &pres);
340 }
341 }
342 }
343 }
344
345 if options.paint_local_player {
346 if state.player_entity().is_some() {
347 let cx = half_w as usize;
348 let cy = half_h as usize;
349 let idx = cy * width + cx;
350 if idx < cells.len() {
351 paint_presentation(
352 &mut cells,
353 &mut cell_fg,
354 idx,
355 &map_presentation::player_presentation(),
356 );
357 }
358 }
359 }
360
361 let mut target_t1_cells = vec![false; cells.len()];
362 let mut target_t2_cells = vec![false; cells.len()];
363 if options.paint_overlays {
364 for (slot, cells_out) in [(1, &mut target_t1_cells), (2, &mut target_t2_cells)] {
365 let target_id = state
366 .combat_slots
367 .iter()
368 .find(|s| s.slot_index == slot)
369 .and_then(|s| s.target_entity_id)
370 .or_else(|| if slot == 1 { state.combat_target } else { None });
371 let Some(target_id) = target_id else {
372 continue;
373 };
374 if let Some(entity) = state.entities.iter().find(|e| e.id == target_id) {
375 if let Some((gx, gy)) = world_to_grid(
376 entity.transform.position.x,
377 entity.transform.position.y,
378 px,
379 py,
380 half_w,
381 half_h,
382 width,
383 height,
384 ) {
385 let idx = gy * width + gx;
386 if idx < cells_out.len() {
387 cells_out[idx] = true;
388 }
389 }
390 }
391 }
392 }
393
394 if options.paint_overlays {
395 if let Some((tx, ty)) = map_target {
396 if let Some((gx, gy)) = world_to_grid(
397 tx,
398 ty,
399 px,
400 py,
401 half_w,
402 half_h,
403 width,
404 height,
405 ) {
406 let idx = gy * width + gx;
407 if idx < cells.len() {
408 cells[idx] = "X".into();
409 cell_fg[idx] = Some(RgbColor::YELLOW);
410 }
411 }
412 }
413 }
414
415 Self {
416 width,
417 height,
418 cells,
419 cell_fg,
420 target_t1_cells,
421 target_t2_cells,
422 origin_x: px,
423 origin_y: py,
424 inside_building,
425 }
426 }
427}
428
429fn paint_presentation(
430 cells: &mut [String],
431 cell_fg: &mut [Option<RgbColor>],
432 idx: usize,
433 pres: &MapPresentation,
434) {
435 cells[idx] = pres.glyph.clone();
436 cell_fg[idx] = Some(pres.color);
437}
438
439fn empty_ground_glyph() -> String {
440 map_presentation::terrain_for(TerrainKindView::Grass).glyph
441}
442
443fn entity_presentation(
444 entity: &flatland_protocol::EntityState,
445 player_id: u64,
446) -> Option<MapPresentation> {
447 if entity.id == player_id {
448 return Some(map_presentation::player_presentation());
449 }
450 if entity
451 .vitals
452 .as_ref()
453 .is_some_and(|v| v.life_state == flatland_protocol::LifeState::Dead)
454 {
455 return Some(map_presentation::corpse_presentation());
456 }
457 Some(map_presentation::entity_fallback(&entity.label))
458}
459
460fn paint_interior_background(
461 cells: &mut [String],
462 cell_fg: &mut [Option<RgbColor>],
463 _width: usize,
464 _height: usize,
465 interior: &InteriorMapView,
466) {
467 let bg = crate::color::parse_color(&interior.background_color).unwrap_or(RgbColor::BLACK);
468 for idx in 0..cells.len() {
469 cells[idx] = " ".into();
470 cell_fg[idx] = Some(bg);
471 }
472}
473
474fn paint_interior_rooms(
475 cells: &mut [String],
476 cell_fg: &mut [Option<RgbColor>],
477 width: usize,
478 height: usize,
479 interior: &InteriorMapView,
480 px: f32,
481 py: f32,
482 half_w: i32,
483 half_h: i32,
484) {
485 let default_color = interior
486 .default_floor_color
487 .as_deref()
488 .and_then(crate::color::parse_color)
489 .unwrap_or(RgbColor::rgb(0x2a, 0x2a, 0x2a));
490 for room in &interior.rooms {
491 let floor_color = room
492 .floor_color
493 .as_deref()
494 .and_then(crate::color::parse_color)
495 .unwrap_or(default_color);
496 let glyph = room.floor_glyph.as_deref().unwrap_or(".").to_string();
497 let x0 = room.x0.floor() as i32;
498 let y0 = room.y0.floor() as i32;
499 let x1 = room.x1.ceil() as i32 - 1;
500 let y1 = room.y1.ceil() as i32 - 1;
501 for wy in y0..=y1 {
502 for wx in x0..=x1 {
503 if let Some((gx, gy)) = world_to_grid(
504 wx as f32,
505 wy as f32,
506 px,
507 py,
508 half_w,
509 half_h,
510 width,
511 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) -> Vec<(f32, f32)> {
526 let mut gaps: Vec<(f32, f32)> = interior
527 .room_doors
528 .iter()
529 .filter_map(|d| {
530 doors
531 .iter()
532 .find(|door| door.id == d.id)
533 .filter(|door| door.open)
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 near_door_gap(wx: i32, wy: i32, door_gaps: &[(f32, f32)]) -> bool {
546 door_gaps.iter().any(|(dx, dy)| {
547 (wx as f32 - dx).abs() < 0.75 && (wy as f32 - dy).abs() < 0.75
548 })
549}
550
551fn paint_terrain(
552 cells: &mut [String],
553 cell_fg: &mut [Option<RgbColor>],
554 width: usize,
555 height: usize,
556 state: &GameState,
557 px: f32,
558 py: f32,
559 _half_w: i32,
560 _half_h: i32,
561) {
562 for gy in 0..height {
563 for gx in 0..width {
564 let Some((wx, wy)) = grid_to_world(gx, gy, px, py, width, height) else {
565 continue;
566 };
567 let zone = state.terrain_zone_at(wx, wy);
568 let kind = zone
569 .map(|z| z.kind)
570 .unwrap_or(TerrainKindView::Grass);
571 let elev = zone.map(|z| z.elevation).unwrap_or(0.0);
572 let style = map_presentation::terrain_for_zone(
573 kind,
574 elev,
575 zone.and_then(|z| z.glyph.as_deref()),
576 zone.and_then(|z| z.color.as_deref()),
577 );
578 let idx = gy * width + gx;
579 paint_presentation(cells, cell_fg, idx, &style);
580 }
581 }
582}
583
584fn paint_well(
585 cells: &mut [String],
586 cell_fg: &mut [Option<RgbColor>],
587 width: usize,
588 height: usize,
589 building: &BuildingView,
590 px: f32,
591 py: f32,
592 half_w: i32,
593 half_h: i32,
594) {
595 let hw = building.width_m / 2.0;
596 let hd = building.depth_m / 2.0;
597 let x0 = (building.x - hw).floor() as i32;
598 let y0 = (building.y - hd).floor() as i32;
599 let x1 = (building.x + hw).ceil() as i32 - 1;
600 let y1 = (building.y + hd).ceil() as i32 - 1;
601 let water = map_presentation::shallow_water_presentation();
602 let center = map_presentation::well_center_presentation();
603
604 for wy in y0..=y1 {
605 for wx in x0..=x1 {
606 let pres = if wx == building.x.round() as i32 && wy == building.y.round() as i32 {
607 center.clone()
608 } else {
609 water.clone()
610 };
611 if let Some((gx, gy)) = world_to_grid(
612 wx as f32,
613 wy as f32,
614 px,
615 py,
616 half_w,
617 half_h,
618 width,
619 height,
620 ) {
621 let idx = gy * width + gx;
622 if !is_wall(&cells[idx]) {
623 paint_presentation(cells, cell_fg, idx, &pres);
624 }
625 }
626 }
627 }
628}
629
630fn paint_building_walls_centered(
631 cells: &mut [String],
632 cell_fg: &mut [Option<RgbColor>],
633 width: usize,
634 height: usize,
635 building: &BuildingView,
636 px: f32,
637 py: f32,
638 half_w: i32,
639 half_h: i32,
640) {
641 let hw = building.width_m / 2.0;
642 let hd = building.depth_m / 2.0;
643 paint_building_walls(
644 cells,
645 cell_fg,
646 width,
647 height,
648 building.x - hw,
649 building.y - hd,
650 building.width_m,
651 building.depth_m,
652 px,
653 py,
654 half_w,
655 half_h,
656 &[],
657 );
658}
659
660fn paint_building_walls(
662 cells: &mut [String],
663 cell_fg: &mut [Option<RgbColor>],
664 width: usize,
665 height: usize,
666 origin_x: f32,
667 origin_y: f32,
668 width_m: f32,
669 depth_m: f32,
670 px: f32,
671 py: f32,
672 half_w: i32,
673 half_h: i32,
674 door_gaps: &[(f32, f32)],
675) {
676 let x0 = origin_x.floor() as i32;
677 let y0 = origin_y.floor() as i32;
678 let x1 = (origin_x + width_m).ceil() as i32 - 1;
679 let y1 = (origin_y + depth_m).ceil() as i32 - 1;
680
681 if x1 < x0 || y1 < y0 {
682 return;
683 }
684
685 for wx in x0..=x1 {
686 if !near_door_gap(wx, y0, door_gaps) {
687 paint_wall_cell(cells, cell_fg, width, height, wx, y0, px, py, half_w, half_h, "-");
688 }
689 if !near_door_gap(wx, y1, door_gaps) {
690 paint_wall_cell(cells, cell_fg, width, height, wx, y1, px, py, half_w, half_h, "-");
691 }
692 }
693 for wy in y0 + 1..y1 {
694 if !near_door_gap(x0, wy, door_gaps) {
695 paint_wall_cell(cells, cell_fg, width, height, x0, wy, px, py, half_w, half_h, "|");
696 }
697 if !near_door_gap(x1, wy, door_gaps) {
698 paint_wall_cell(cells, cell_fg, width, height, x1, wy, px, py, half_w, half_h, "|");
699 }
700 }
701 if !near_door_gap(x0, y0, door_gaps) {
702 paint_wall_cell(cells, cell_fg, width, height, x0, y0, px, py, half_w, half_h, "+");
703 }
704 if !near_door_gap(x1, y0, door_gaps) {
705 paint_wall_cell(cells, cell_fg, width, height, x1, y0, px, py, half_w, half_h, "+");
706 }
707 if !near_door_gap(x0, y1, door_gaps) {
708 paint_wall_cell(cells, cell_fg, width, height, x0, y1, px, py, half_w, half_h, "+");
709 }
710 if !near_door_gap(x1, y1, door_gaps) {
711 paint_wall_cell(cells, cell_fg, width, height, x1, y1, px, py, half_w, half_h, "+");
712 }
713}
714
715fn paint_wall_cell(
716 cells: &mut [String],
717 cell_fg: &mut [Option<RgbColor>],
718 width: usize,
719 height: usize,
720 wx: i32,
721 wy: i32,
722 px: f32,
723 py: f32,
724 half_w: i32,
725 half_h: i32,
726 ch: &str,
727) {
728 let Some((gx, gy)) = world_to_grid(
729 wx as f32,
730 wy as f32,
731 px,
732 py,
733 half_w,
734 half_h,
735 width,
736 height,
737 ) else {
738 return;
739 };
740 let idx = gy * width + gx;
741 let ground = empty_ground_glyph();
742 if cells[idx] == ground || is_wall(&cells[idx]) {
743 cells[idx] = merge_wall_corner(&cells[idx], ch, &ground);
744 cell_fg[idx] = Some(map_presentation::wall_presentation().color);
745 }
746}
747
748fn is_wall(glyph: &str) -> bool {
749 matches!(
750 glyph.chars().next(),
751 Some('+' | '-' | '|')
752 )
753}
754
755fn can_paint_world_object(glyph: &str, _ground: &str, _player_glyph: &str) -> bool {
757 !is_wall(glyph)
758}
759
760fn merge_wall_corner(existing: &str, incoming: &str, ground: &str) -> String {
761 if existing == ground {
762 return incoming.to_string();
763 }
764 if existing == incoming {
765 return existing.to_string();
766 }
767 "+".to_string()
768}
769
770fn world_to_grid(
771 x: f32,
772 y: f32,
773 px: f32,
774 py: f32,
775 half_w: i32,
776 half_h: i32,
777 width: usize,
778 height: usize,
779) -> Option<(usize, usize)> {
780 let dx = (x - px).round() as i32;
781 let dy = (y - py).round() as i32;
782
783 if dx.abs() > half_w || dy.abs() > half_h {
784 return None;
785 }
786
787 let gx = half_w + dx;
788 let gy = half_h - dy;
789
790 if gx < 0 || gy < 0 {
791 return None;
792 }
793 let gx = gx as usize;
794 let gy = gy as usize;
795 if gx >= width || gy >= height {
796 return None;
797 }
798 Some((gx, gy))
799}
800
801pub fn grid_to_world(
803 gx: usize,
804 gy: usize,
805 px: f32,
806 py: f32,
807 view_w: usize,
808 view_h: usize,
809) -> Option<(f32, f32)> {
810 if gx >= view_w || gy >= view_h {
811 return None;
812 }
813 let half_w = (view_w / 2) as i32;
814 let half_h = (view_h / 2) as i32;
815 let dx = gx as i32 - half_w;
816 let dy = half_h - gy as i32;
817 Some((px + dx as f32, py + dy as f32))
818}
819
820#[cfg(test)]
821mod tests {
822 use super::*;
823 use flatland_protocol::{BuildingView, EntityState, PlayerVitals, PrimaryAttributes, Transform, WorldCoord};
824
825 fn state_with_building(building: BuildingView) -> GameState {
826 GameState {
827 session_id: 1,
828 entity_id: 1,
829 character_id: None,
830 tick: 0,
831 chunk_rev: 0,
832 content_rev: 0,
833 entities: vec![EntityState {
834 id: 1,
835 label: "You".into(),
836 transform: Transform {
837 position: WorldCoord::surface(148.0, 118.0),
838 yaw: 0.0,
839 velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
840 },
841 vitals: Some(PlayerVitals::default()),
842 attributes: Some(PrimaryAttributes::default()),
843 skills: Some(flatland_protocol::PlayerSkills::default()),
844 inside_building: None,
845 tile_id: None,
846 presentation_state: None,
847 sprite_mode: None,
848 }],
849 player: None,
850 resource_nodes: vec![],
851 ground_drops: vec![],
852 placed_containers: vec![],
853 buildings: vec![building],
854 doors: vec![],
855 interior_map: None,
856 npcs: vec![],
857 blueprints: vec![],
858 world_width_m: 256.0,
859 world_height_m: 256.0,
860 terrain_zones: vec![],
861 z_platforms: vec![],
862 z_transitions: vec![],
863 world_clock: flatland_protocol::WorldClock::default(),
864 inventory: Default::default(),
865 inventory_hints: Default::default(),
866 logs: Default::default(),
867 intents_sent: 0,
868 ticks_received: 0,
869 connected: true,
870 disconnect_reason: None,
871 show_stats: false,
872 show_craft_menu: false,
873 craft_menu_index: 0,
874 craft_batch_quantity: 1,
875 show_shop_menu: false,
876 shop_catalog: None,
877 shop_tab: flatland_client_lib::ShopTab::default(),
878 shop_menu_index: 0,
879 shop_quantity: 1,
880 shop_trade_log: std::collections::VecDeque::new(),
881 show_npc_verb_menu: false,
882 npc_verb_target: None,
883 npc_verb_index: 0,
884 show_npc_chat: false,
885 npc_chat: None,
886 show_inventory_menu: false,
887 inventory_menu_index: 0,
888 show_move_picker: false,
889 show_rename_prompt: false,
890 rename_buffer: String::new(),
891 move_picker_index: 0,
892 move_picker: None,
893 show_destroy_picker: false,
894 destroy_confirm_pending: false,
895 destroy_picker: None,
896 combat_target: None,
897 combat_target_label: None,
898 in_combat: false,
899 auto_attack: true,
900 combat_has_los: false,
901 attack_cd_ticks: 0,
902 gcd_ticks: 0,
903 weapon_ability_id: "unarmed".into(),
904 mainhand_template_id: None,
905 mainhand_label: None,
906 worn: std::collections::BTreeMap::new(),
907 carry_mass: 0.0,
908 carry_mass_max: 0.0,
909 encumbrance: flatland_protocol::EncumbranceState::Light,
910 inventory_stacks: Vec::new(),
911 keychain_stacks: Vec::new(),
912 combat_target_detail: None,
913 cast_progress: None,
914 ability_cooldowns: Vec::new(),
915 blocking_active: false,
916 max_target_slots: 1,
917 combat_slots: Vec::new(),
918 rotation_presets: Vec::new(),
919 show_loadout_menu: false,
920 show_keychain_menu: false,
921 keychain_menu_index: 0,
922 show_rotation_editor: false,
923 loadout_menu_index: 0,
924 rotation_editor: Default::default(),
925 harvest_in_progress: false,
926 harvest_started_at: None,
927 pending_craft_ack: None,
928 quest_log: Vec::new(),
929 interactables: Vec::new(),
930 show_quest_offer: false,
931 pending_quest_offer: None,
932 show_quest_menu: false,
933 quest_menu_index: 0,
934 quest_withdraw_confirm: false,
935 }
936 }
937
938 #[test]
939 fn shallow_water_terrain_paints_tilde() {
940 use flatland_protocol::TerrainZoneView;
941 let mut state = state_with_building(BuildingView {
942 id: "x".into(),
943 label: "X".into(),
944 x: 128.0,
945 y: 128.0,
946 width_m: 1.0,
947 depth_m: 1.0,
948 interior_blueprint: None,
949 tags: vec![],
950 });
951 state.terrain_zones.push(TerrainZoneView {
952 id: "pond".into(),
953 x0: 126.0,
954 y0: 126.0,
955 x1: 130.0,
956 y1: 130.0,
957 kind: TerrainKindView::ShallowWater,
958 elevation: -0.5,
959 glyph: None,
960 color: None,
961 tile_id: None,
962 });
963 state.entities = vec![EntityState {
964 id: 1,
965 label: "You".into(),
966 transform: Transform {
967 position: WorldCoord::surface(128.0, 128.0),
968 yaw: 0.0,
969 velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
970 },
971 vitals: Some(PlayerVitals::default()),
972 attributes: Some(PrimaryAttributes::default()),
973 skills: Some(flatland_protocol::PlayerSkills::default()),
974 inside_building: None,
975 tile_id: None,
976 presentation_state: None,
977 sprite_mode: None,
978 }];
979 state.player = state.entities.first().cloned();
980 let view = WorldView::build_with_target(&state, 9, 9, None);
981 let flat = view.cells.join("");
982 let water = map_presentation::shallow_water_presentation();
983 assert!(
984 flat.contains(&water.glyph),
985 "expected water tiles ({:?}): {flat}",
986 water.glyph
987 );
988 assert!(
989 view.cell_fg.iter().any(|c| *c == Some(water.color)),
990 "expected water color on terrain cells"
991 );
992 }
993
994 #[test]
995 fn zone_glyph_and_color_overrides_paint_on_map() {
996 use flatland_protocol::TerrainZoneView;
997 let mut state = state_with_building(BuildingView {
998 id: "x".into(),
999 label: "X".into(),
1000 x: 128.0,
1001 y: 128.0,
1002 width_m: 1.0,
1003 depth_m: 1.0,
1004 interior_blueprint: None,
1005 tags: vec![],
1006 });
1007 state.terrain_zones.push(TerrainZoneView {
1008 id: "marked".into(),
1009 x0: 126.0,
1010 y0: 126.0,
1011 x1: 130.0,
1012 y1: 130.0,
1013 kind: TerrainKindView::Grass,
1014 elevation: 0.0,
1015 glyph: Some("%".into()),
1016 color: Some("magenta".into()),
1017 tile_id: None,
1018 });
1019 state.entities = vec![EntityState {
1020 id: 1,
1021 label: "You".into(),
1022 transform: Transform {
1023 position: WorldCoord::surface(128.0, 128.0),
1024 yaw: 0.0,
1025 velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
1026 },
1027 vitals: Some(PlayerVitals::default()),
1028 attributes: Some(PrimaryAttributes::default()),
1029 skills: Some(flatland_protocol::PlayerSkills::default()),
1030 inside_building: None,
1031 tile_id: None,
1032 presentation_state: None,
1033 sprite_mode: None,
1034 }];
1035 state.player = state.entities.first().cloned();
1036 let view = WorldView::build_with_target(&state, 9, 9, None);
1037 let flat = view.cells.join("");
1038 assert!(flat.contains('%'), "expected custom zone glyph on map: {flat}");
1039 assert!(
1040 view.cell_fg.iter().any(|c| *c == Some(RgbColor::MAGENTA)),
1041 "expected custom zone color on terrain cells"
1042 );
1043 }
1044
1045 #[test]
1046 fn well_paints_water_ring_and_center() {
1047 let building = BuildingView {
1048 id: "town_well".into(),
1049 label: "Well".into(),
1050 x: 122.0,
1051 y: 106.0,
1052 width_m: 3.0,
1053 depth_m: 3.0,
1054 interior_blueprint: None,
1055 tags: vec!["well".into()],
1056 };
1057 let mut state = state_with_building(building);
1058 state.entities = vec![EntityState {
1059 id: 1,
1060 label: "You".into(),
1061 transform: Transform {
1062 position: WorldCoord::surface(120.0, 106.0),
1063 yaw: 0.0,
1064 velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
1065 },
1066 vitals: Some(PlayerVitals::default()),
1067 attributes: Some(PrimaryAttributes::default()),
1068 skills: Some(flatland_protocol::PlayerSkills::default()),
1069 inside_building: None,
1070 tile_id: None,
1071 presentation_state: None,
1072 sprite_mode: None,
1073 }];
1074 state.player = state.entities.first().cloned();
1075 let view = WorldView::build_with_target(&state, 9, 9, None);
1076 let flat = view.cells.join("");
1077 assert!(flat.contains('~'), "expected well water: {flat}");
1078 assert!(flat.contains('O'), "expected well center: {flat}");
1079 }
1080
1081 #[test]
1082 fn broker_hut_draws_wall_outline() {
1083 let building = BuildingView {
1084 id: "broker_hut".into(),
1085 label: "Broker's Hut".into(),
1086 x: 148.0,
1087 y: 118.0,
1088 width_m: 8.0,
1089 depth_m: 6.0,
1090 interior_blueprint: None,
1091 tags: vec![],
1092 };
1093 let mut state = state_with_building(building);
1094 state.player = state.entities.first().cloned();
1095 let view = WorldView::build_with_target(&state, 25, 15, None);
1096 let flat = view.cells.join("");
1097 assert!(flat.contains('+'), "expected corners: {flat}");
1098 assert!(flat.contains('-'), "expected horiz walls: {flat}");
1099 assert!(flat.contains('|'), "expected vert walls: {flat}");
1100 }
1101
1102 #[test]
1103 fn interior_map_renders_rooms_and_walls() {
1104 use flatland_protocol::{InteriorMapView, InteriorRoomView};
1105 let building = BuildingView {
1106 id: "broker_hut".into(),
1107 label: "Broker's Hut".into(),
1108 x: 148.0,
1109 y: 118.0,
1110 width_m: 8.0,
1111 depth_m: 6.0,
1112 interior_blueprint: Some("broker_hut".into()),
1113 tags: vec![],
1114 };
1115 let mut state = state_with_building(building);
1116 state.interior_map = Some(InteriorMapView {
1117 building_id: "broker_hut".into(),
1118 blueprint_id: "broker_hut".into(),
1119 background_color: "#000000".into(),
1120 default_floor_color: Some("#2a2a2a".into()),
1121 floor_height_m: 3.0,
1122 rooms: vec![InteriorRoomView {
1123 id: "main".into(),
1124 label: "Main".into(),
1125 floor: 0,
1126 x0: 0.0,
1127 y0: 0.0,
1128 x1: 8.5,
1129 y1: 7.0,
1130 floor_color: None,
1131 floor_glyph: None,
1132 }],
1133 room_doors: vec![],
1134 });
1135 state.entities = vec![EntityState {
1136 id: 1,
1137 label: "You".into(),
1138 transform: Transform {
1139 position: WorldCoord::surface(4.0, 3.0),
1140 yaw: 0.0,
1141 velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
1142 },
1143 vitals: Some(PlayerVitals::default()),
1144 attributes: Some(PrimaryAttributes::default()),
1145 skills: Some(flatland_protocol::PlayerSkills::default()),
1146 inside_building: Some("broker_hut".into()),
1147 tile_id: None,
1148 presentation_state: None,
1149 sprite_mode: None,
1150 }];
1151 state.player = state.entities.first().cloned();
1152 let view = WorldView::build_with_target(&state, 25, 15, None);
1153 assert_eq!(view.inside_building.as_deref(), Some("broker_hut"));
1154 let flat = view.cells.join("");
1155 assert!(flat.contains('+'), "expected interior walls: {flat}");
1156 assert!(flat.contains('@'), "expected player marker: {flat}");
1157 }
1158
1159 #[test]
1160 fn stale_interior_map_renders_outdoor_when_outside() {
1161 use flatland_protocol::{InteriorMapView, InteriorRoomView};
1162 let building = BuildingView {
1163 id: "town_hall".into(),
1164 label: "Town Hall".into(),
1165 x: 163.0,
1166 y: 137.0,
1167 width_m: 20.0,
1168 depth_m: 10.0,
1169 interior_blueprint: Some("town_hall".into()),
1170 tags: vec![],
1171 };
1172 let mut state = state_with_building(building);
1173 state.interior_map = Some(InteriorMapView {
1174 building_id: "town_hall".into(),
1175 blueprint_id: "town_hall".into(),
1176 background_color: "#000000".into(),
1177 default_floor_color: Some("#2a2a2a".into()),
1178 floor_height_m: 3.0,
1179 rooms: vec![InteriorRoomView {
1180 id: "main_hall".into(),
1181 label: "Main".into(),
1182 floor: 0,
1183 x0: -3.5,
1184 y0: -8.0,
1185 x1: 18.5,
1186 y1: 6.0,
1187 floor_color: None,
1188 floor_glyph: None,
1189 }],
1190 room_doors: vec![],
1191 });
1192 state.player = state.entities.first().cloned();
1193 let view = WorldView::build_with_target(&state, 25, 15, None);
1194 assert!(view.inside_building.is_none());
1195 let flat = view.cells.join("");
1196 let grass = map_presentation::terrain_for(TerrainKindView::Grass).glyph;
1197 assert!(
1198 flat.contains(&grass),
1199 "expected outdoor terrain, not stale interior background: {flat}"
1200 );
1201 assert!(
1202 !flat.chars().all(|c| c == ' ' || c == '@'),
1203 "stale interior_map must not paint black interior when outside"
1204 );
1205 }
1206
1207 #[test]
1208 fn stale_inside_flag_still_renders_outdoor_world() {
1209 use flatland_protocol::ResourceNodeState;
1210 let building = BuildingView {
1211 id: "broker_hut".into(),
1212 label: "Broker's Hut".into(),
1213 x: 148.0,
1214 y: 118.0,
1215 width_m: 8.0,
1216 depth_m: 6.0,
1217 interior_blueprint: None,
1218 tags: vec![],
1219 };
1220 let mut state = state_with_building(building);
1221 state.entities = vec![EntityState {
1222 id: 1,
1223 label: "You".into(),
1224 transform: Transform {
1225 position: WorldCoord::surface(128.0, 128.0),
1226 yaw: 0.0,
1227 velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
1228 },
1229 vitals: Some(PlayerVitals::default()),
1230 attributes: Some(PrimaryAttributes::default()),
1231 skills: Some(flatland_protocol::PlayerSkills::default()),
1232 inside_building: Some("broker_hut".into()),
1233 tile_id: None,
1234 presentation_state: None,
1235 sprite_mode: None,
1236 }];
1237 state.player = state.entities.first().cloned();
1238 state.resource_nodes.push(flatland_protocol::ResourceNodeView {
1239 id: "oak".into(),
1240 label: "Oak".into(),
1241 x: 126.0,
1242 y: 134.0,
1243 z: 0.0,
1244 item_template: "oak_log".into(),
1245 state: ResourceNodeState::Available,
1246 blocking: true,
1247 blocking_radius_m: 0.8,
1248 tile_id: None,
1249 sprite_mode: None,
1250 presentation_state: None,
1251 });
1252 let view = WorldView::build_with_target(&state, 25, 15, None);
1253 assert_eq!(
1254 view.inside_building.as_deref(),
1255 Some("broker_hut"),
1256 "server inside flag is authoritative"
1257 );
1258 let flat = view.cells.join("");
1259 let oak = map_presentation::resource_for(&flatland_protocol::ResourceNodeView {
1260 id: "oak".into(),
1261 label: "Oak".into(),
1262 x: 0.0,
1263 y: 0.0,
1264 z: 0.0,
1265 item_template: "oak_log".into(),
1266 state: flatland_protocol::ResourceNodeState::Available,
1267 blocking: true,
1268 blocking_radius_m: 0.8,
1269 tile_id: None,
1270 sprite_mode: None,
1271 presentation_state: None,
1272 });
1273 assert!(
1274 flat.contains(&oak.glyph),
1275 "expected nearby tree ({:?}): {flat}",
1276 oak.glyph
1277 );
1278 assert!(flat.contains('@'), "expected player: {flat}");
1279 }
1280
1281 #[test]
1282 fn inside_building_flag_selects_active_instance() {
1283 let town = BuildingView {
1284 id: "town_hall".into(),
1285 label: "Town Hall".into(),
1286 x: 160.0,
1287 y: 136.0,
1288 width_m: 20.0,
1289 depth_m: 10.0,
1290 interior_blueprint: Some("town_hall".into()),
1291 tags: vec![],
1292 };
1293 let guild = BuildingView {
1294 id: "guild_hall".into(),
1295 label: "Guild Hall".into(),
1296 x: 164.0,
1297 y: 152.0,
1298 width_m: 20.0,
1299 depth_m: 14.0,
1300 interior_blueprint: Some("guild_hall".into()),
1301 tags: vec![],
1302 };
1303 let mut state = state_with_building(town);
1304 state.buildings.push(guild);
1305 state.entities = vec![EntityState {
1306 id: 1,
1307 label: "You".into(),
1308 transform: Transform {
1309 position: WorldCoord::surface(4.0, 3.0),
1310 yaw: 0.0,
1311 velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
1312 },
1313 vitals: Some(PlayerVitals::default()),
1314 attributes: Some(PrimaryAttributes::default()),
1315 skills: Some(flatland_protocol::PlayerSkills::default()),
1316 inside_building: Some("guild_hall".into()),
1317 tile_id: None,
1318 presentation_state: None,
1319 sprite_mode: None,
1320 }];
1321 state.player = state.entities.first().cloned();
1322 state.world_width_m = 256.0;
1323 state.world_height_m = 256.0;
1324
1325 let view = WorldView::build_with_target(&state, 25, 15, None);
1326 assert_eq!(view.inside_building.as_deref(), Some("guild_hall"));
1327 }
1328
1329 #[test]
1330 fn combat_target_marks_creature_cell() {
1331 let building = BuildingView {
1332 id: "x".into(),
1333 label: "X".into(),
1334 x: 128.0,
1335 y: 128.0,
1336 width_m: 1.0,
1337 depth_m: 1.0,
1338 interior_blueprint: None,
1339 tags: vec![],
1340 };
1341 let mut state = state_with_building(building);
1342 state.entities = vec![
1343 EntityState {
1344 id: 1,
1345 label: "You".into(),
1346 transform: Transform {
1347 position: WorldCoord::surface(100.0, 100.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 presentation_state: None,
1357 sprite_mode: None,
1358 },
1359 EntityState {
1360 id: 42,
1361 label: "Rabbit".into(),
1362 transform: Transform {
1363 position: WorldCoord::surface(103.0, 100.0),
1364 yaw: 0.0,
1365 velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
1366 },
1367 vitals: None,
1368 attributes: None,
1369 skills: None,
1370 inside_building: None,
1371 tile_id: None,
1372 presentation_state: None,
1373 sprite_mode: None,
1374 },
1375 ];
1376 state.player = state.entities.first().cloned();
1377 state.combat_target = Some(42);
1378 state.combat_target_label = Some("Rabbit".into());
1379
1380 let view = WorldView::build_with_target(&state, 25, 15, None);
1381 let marked: usize = view
1382 .target_t1_cells
1383 .iter()
1384 .chain(view.target_t2_cells.iter())
1385 .filter(|b| **b)
1386 .count();
1387 assert_eq!(marked, 1, "exactly one targeted cell");
1388 let idx = view
1389 .target_t1_cells
1390 .iter()
1391 .chain(view.target_t2_cells.iter())
1392 .position(|b| *b)
1393 .expect("target cell");
1394 assert_eq!(view.cells[idx], "R");
1395 }
1396
1397 #[test]
1398 fn grid_to_world_roundtrips_center() {
1399 let px = 10.0;
1400 let py = 20.0;
1401 let view_w = 11;
1402 let view_h = 11;
1403 let half_w = (view_w / 2) as i32;
1404 let half_h = (view_h / 2) as i32;
1405 let (gx, gy) = world_to_grid(12.0, 18.0, px, py, half_w, half_h, view_w, view_h)
1406 .expect("in view");
1407 let (wx, wy) = grid_to_world(gx, gy, px, py, view_w, view_h).expect("inverse");
1408 assert!((wx - 12.0).abs() < 0.01);
1409 assert!((wy - 18.0).abs() < 0.01);
1410 }
1411
1412 #[test]
1413 fn vertical_axis_quantizes_to_one_meter() {
1414 let px = 0.0;
1415 let py = 0.0;
1416 let view_w = 21;
1417 let view_h = 21;
1418 let half_w = (view_w / 2) as i32;
1419 let half_h = (view_h / 2) as i32;
1420 let (gx, gy) =
1421 world_to_grid(3.0, 3.0, px, py, half_w, half_h, view_w, view_h).expect("in view");
1422 let (wx, wy) = grid_to_world(gx, gy, px, py, view_w, view_h).expect("inverse");
1423 assert!((wx - 3.0).abs() < 0.01, "x should stay exact to 1m: got {wx}");
1424 assert!((wy - 3.0).abs() < 0.01, "y should stay exact to 1m: got {wy}");
1425 }
1426
1427 #[test]
1428 fn square_extent_spans_equal_rows_and_columns() {
1429 let px = 0.0;
1430 let py = 0.0;
1431 let half_w = 50;
1432 let half_h = 50;
1433 let width = 101;
1434 let height = 101;
1435 let (gx0, gy0) =
1436 world_to_grid(-4.0, -4.0, px, py, half_w, half_h, width, height).expect("in view");
1437 let (gx1, gy1) =
1438 world_to_grid(4.0, 4.0, px, py, half_w, half_h, width, height).expect("in view");
1439 let cols_spanned = (gx1 as i32 - gx0 as i32).unsigned_abs();
1440 let rows_spanned = (gy1 as i32 - gy0 as i32).unsigned_abs();
1441 assert_eq!(cols_spanned, 8, "8m wide should span 8 columns");
1442 assert_eq!(rows_spanned, 8, "8m tall should span 8 rows");
1443 }
1444
1445 #[test]
1446 fn resource_paints_on_terrain_cell_for_same_world_coords() {
1447 use flatland_protocol::{ResourceNodeState, ResourceNodeView, TerrainZoneView};
1448
1449 let px = 128.0;
1450 let py = 128.0;
1451 let rx = 131.0;
1452 let ry = 132.0;
1453 let mut state = state_with_building(BuildingView {
1454 id: "x".into(),
1455 label: "X".into(),
1456 x: 128.0,
1457 y: 128.0,
1458 width_m: 1.0,
1459 depth_m: 1.0,
1460 interior_blueprint: None,
1461 tags: vec![],
1462 });
1463 state.terrain_zones.push(TerrainZoneView {
1464 id: "pond".into(),
1465 x0: rx,
1466 y0: ry,
1467 x1: rx + 1.0,
1468 y1: ry + 1.0,
1469 kind: TerrainKindView::ShallowWater,
1470 elevation: -0.5,
1471 glyph: None,
1472 color: None,
1473 tile_id: None,
1474 });
1475 state.resource_nodes.push(ResourceNodeView {
1476 id: "oak".into(),
1477 label: "Oak".into(),
1478 x: rx,
1479 y: ry,
1480 z: 0.0,
1481 item_template: "oak_log".into(),
1482 state: ResourceNodeState::Available,
1483 blocking: true,
1484 blocking_radius_m: 0.8,
1485 tile_id: None,
1486 sprite_mode: None,
1487 presentation_state: None,
1488 });
1489 state.entities[0].transform.position = WorldCoord::surface(px, py);
1490 state.player = state.entities.first().cloned();
1491
1492 let view = WorldView::build_with_target(&state, 25, 15, None);
1493 let half_w = (view.width / 2) as i32;
1494 let half_h = (view.height / 2) as i32;
1495 let (gx, gy) = world_to_grid(rx, ry, px, py, half_w, half_h, view.width, view.height)
1496 .expect("resource in view");
1497 let idx = gy * view.width + gx;
1498 let oak = map_presentation::resource_for(&state.resource_nodes[0]);
1499 assert_eq!(
1500 view.cells[idx], oak.glyph,
1501 "resource should paint on the grid cell for its world coords"
1502 );
1503 let (wx, wy) = grid_to_world(gx, gy, px, py, view.width, view.height).expect("inverse");
1504 assert!(
1505 (wx - rx).abs() < 0.01 && (wy - ry).abs() < 0.01,
1506 "resource grid cell should sample terrain at ({wx}, {wy}), expected ({rx}, {ry})"
1507 );
1508 }
1509
1510 #[test]
1511 fn chest_and_loot_paint_on_non_grass_terrain() {
1512 use flatland_protocol::{GroundDropView, PlacedContainerView, TerrainZoneView};
1513
1514 let px = 50.0;
1515 let py = 50.0;
1516 let cx = 53.0;
1517 let cy = 52.0;
1518 let lx = 54.0;
1519 let ly = 52.0;
1520 let mut state = state_with_building(BuildingView {
1521 id: "x".into(),
1522 label: "X".into(),
1523 x: 50.0,
1524 y: 50.0,
1525 width_m: 1.0,
1526 depth_m: 1.0,
1527 interior_blueprint: None,
1528 tags: vec![],
1529 });
1530 state.terrain_zones.push(TerrainZoneView {
1531 id: "trail".into(),
1532 x0: 52.0,
1533 y0: 51.0,
1534 x1: 56.0,
1535 y1: 54.0,
1536 kind: TerrainKindView::Trail,
1537 elevation: 0.0,
1538 glyph: None,
1539 color: None,
1540 tile_id: None,
1541 });
1542 state.placed_containers.push(PlacedContainerView {
1543 id: "chest_1".into(),
1544 template_id: "wood_chest".into(),
1545 display_name: "Storage".into(),
1546 x: cx,
1547 y: cy,
1548 z: 0.0,
1549 locked: false,
1550 accessible: true,
1551 owner_character_id: None,
1552 contents: vec![],
1553 lock_id: None,
1554 capacity_volume: Some(40.0),
1555 item_instance_id: None,
1556 tile_id: None,
1557 });
1558 state.ground_drops.push(GroundDropView {
1559 id: "drop_1".into(),
1560 template_id: "lumber".into(),
1561 quantity: 2,
1562 x: lx,
1563 y: ly,
1564 z: 0.0,
1565 tile_id: None,
1566 });
1567 state.entities[0].transform.position = WorldCoord::surface(px, py);
1568 state.player = state.entities.first().cloned();
1569
1570 let view = WorldView::build_with_target(&state, 25, 15, None);
1571 let half_w = (view.width / 2) as i32;
1572 let half_h = (view.height / 2) as i32;
1573 let (gx, gy) =
1574 world_to_grid(cx, cy, px, py, half_w, half_h, view.width, view.height).expect("chest");
1575 let chest = map_presentation::chest_presentation(false);
1576 assert_eq!(
1577 view.cells[gy * view.width + gx],
1578 chest.glyph,
1579 "chest must paint on trail/non-grass cells"
1580 );
1581 let (gx2, gy2) =
1582 world_to_grid(lx, ly, px, py, half_w, half_h, view.width, view.height).expect("loot");
1583 let loot = map_presentation::loot_presentation();
1584 assert_eq!(
1585 view.cells[gy2 * view.width + gx2],
1586 loot.glyph,
1587 "ground loot must paint on trail/non-grass cells"
1588 );
1589 }
1590
1591 #[test]
1592 fn build_with_anchor_sets_view_origin() {
1593 let mut state = state_with_building(BuildingView {
1594 id: "x".into(),
1595 label: "X".into(),
1596 x: 128.0,
1597 y: 128.0,
1598 width_m: 1.0,
1599 depth_m: 1.0,
1600 interior_blueprint: None,
1601 tags: vec![],
1602 });
1603 state.entities[0].transform.position = WorldCoord::surface(100.0, 200.0);
1604 state.player = state.entities.first().cloned();
1605 let view = WorldView::build_with_anchor(
1606 &state,
1607 21,
1608 15,
1609 12.3,
1610 40.7,
1611 None,
1612 WorldViewOptions::terrain_only(),
1613 );
1614 assert!((view.origin_x - 12.3).abs() < 0.01);
1615 assert!((view.origin_y - 40.7).abs() < 0.01);
1616 }
1617
1618 #[test]
1619 fn skip_local_player_glyph_when_paint_local_player_false() {
1620 let mut state = state_with_building(BuildingView {
1621 id: "x".into(),
1622 label: "X".into(),
1623 x: 10.0,
1624 y: 10.0,
1625 width_m: 1.0,
1626 depth_m: 1.0,
1627 interior_blueprint: None,
1628 tags: vec![],
1629 });
1630 state.entities[0].transform.position = WorldCoord::surface(128.0, 128.0);
1631 state.player = state.entities.first().cloned();
1632 let player_glyph = map_presentation::player_presentation().glyph;
1633
1634 let with_player = WorldView::build_with_options(
1635 &state,
1636 21,
1637 15,
1638 None,
1639 WorldViewOptions::default(),
1640 );
1641 let cx = with_player.width / 2;
1642 let cy = with_player.height / 2;
1643 assert_eq!(
1644 with_player.cells[cy * with_player.width + cx],
1645 player_glyph,
1646 "default build paints @"
1647 );
1648
1649 let without = WorldView::build_with_options(
1650 &state,
1651 21,
1652 15,
1653 None,
1654 WorldViewOptions::terrain_only(),
1655 );
1656 assert_ne!(
1657 without.cells[cy * without.width + cx],
1658 player_glyph,
1659 "gfx sprite mode must not paint local player @"
1660 );
1661 }
1662}